Skip to content

Forking

A fork is a brand-new database, in the same workspace, that copies the source’s schema — tables, indexes, triggers, and views. By default the fork starts with empty tables; check Copy data (or pass includeData) to copy the rows too. Once forked, the two databases are fully independent — writes to one don’t affect the other, and the source is never locked or paused while you fork it.

In the console: Database → Fork in the page header. Pick a name and slug, optionally tick Copy data too, and PerSQL provisions the new database and replays the source into it.

Forked databases remember where they came from. The detail page shows a “Forked from <source-slug> · 3d ago” breadcrumb that links back to the parent. The lineage stays even if the parent is later deleted (the link goes dead, but the label remains).

The same fields are exposed on the API:

db.forkedFrom; // { id, slug, name } | null
db.forkedAt; // ISO string | null

Only GET /api/namespaces/:ns/databases/:slug resolves the parent’s slug+name; the workspace list endpoint returns just the raw forkedAt to keep the response cheap.

  • Tables, indexes, triggers, and views — the full schema (every DDL object in sqlite_master).
  • Rows — only when you ask for them (Copy data / includeData: true). Without it, the fork starts with empty tables; seed it with fixtures or a migration.

A schema-only fork is bounded by the number of schema objects, so it’s instant regardless of the source’s size. A data fork copies every row, so it’s proportional to the data — there is no copy-on-write, and no “instant large fork.”

PerSQL streams the rows in pages, so the copy works for databases of any size and resumes automatically if interrupted. While it runs, the new database stays in provisioning and flips to healthy when the copy finishes; a large copy completes in the background.

The source is never locked. You can fork a live database, and many forks can read the same source at once. The trade-off: a copy of a source that is being written to while you fork it is best-effort. Because the rows are read over a window rather than in a single instant, the fork may capture some values mid-change and may drop rows that point at a parent row the copy didn’t catch (referential orphans are pruned so the fork loads cleanly). For an exact copy, fork a database that isn’t being written to.

  • Saved queries (per-database, scoped to source).
  • Migrations history (per-database).
  • Schedules (per-database).
  • Custom hostnames.
  • API tokens (those are per-workspace and apply to the fork too).
  1. PerSQL reads the source’s schema — the CREATE TABLE/INDEX/TRIGGER/VIEW statements.
  2. A new database row is inserted into the workspace registry with status provisioning.
  3. A fresh database is initialized at the new id and the schema is replayed into it (one transaction).
  4. For a schema-only fork, the new database flips to healthy here. For a data fork, PerSQL then streams the rows page by page into the new database and flips it to healthy once the copy finishes (resuming automatically if interrupted).
  5. On failure, the new database row is cleaned up and the error is surfaced to the user.

Forks can carry a self-destruct timer. Pass ttlDays (1–30) on the REST call, or --ttl 7d on the CLI, and the daily 04:00 UTC cron deletes the fork — the database and its registry row — once the timestamp passes. Pre-existing forks without a TTL are unaffected.

persql db fork acme/orders pr-142 --ttl 7d

The detail page shows an “Auto-deletes in 6d” line; the database list shows an “expires in 6d” badge that turns red under 6 hours.

For PR-style ephemeral databases, the Branches API is usually a better fit: it’s idempotent (PUT-by-ref creates or resets), so CI doesn’t need to track whether the database already exists.

Fork is available on every surface — agents and CI fork the same way you do.

Console REST (cookie session):

POST /api/namespaces/:ns/databases/:db/fork
{ "name": "Orders staging", "slug": "orders-staging", "region": "auto", "ttlDays": 7, "includeData": true }

Programmatic /v1 (bearer token, admin role):

POST /v1/db/:ns/:db/fork
{ "name": "Orders staging", "slug": "orders-staging", "includeData": true }

SDK:

const fork = await db.fork({ slug: "orders-staging", name: "Orders staging", includeData: true });
// fork.status === "provisioning" until the copy finishes

CLI:

persql db fork acme/orders orders-staging --with-data

MCP: the fork_database tool (database, name, slug, optional region / ttlDays / includeData).

ttlDays and includeData are optional (includeData defaults to false — schema only). Manage / admin permission required on the source. The response is the new database row; a data fork comes back as provisioning and becomes healthy once the copy finishes.

  • A schema-only fork’s cost is bounded by the number of schema objects, not the parent’s data size — forking a large database is as cheap as forking an empty one. A data fork copies every row, so its cost and time scale with the data.
  • A data fork of a database that’s being written to is best-effort (see Copying data). Fork an idle database for an exact copy.
  • Copying rows is metered like any read + write — the source’s rows read and the fork’s rows written count toward usage.
  • Cross-workspace fork is not supported in v1 — fork within the workspace, then transfer if needed.