Branching
A branch is an instant copy-on-write fork of your database: a cheap LSN pointer over shared immutable layers that sees the base's committed data but writes in complete isolation. Creating one copies no pages, so you can mint dozens of them off a single base and let each diverge only as it writes.
What a branch is
A branch is not a copy. It is a cheap LSN pointer over shared immutable layers: the database's history is already durable and versioned by LSN, so a branch only has to record a parent and a fork point. From that, every read resolves one of two ways:
- MUST a read at or below the fork LSN falls through to the parent's already-durable history — shared, immutable bytes that the base and every sibling read too.
- MUST a read above the fork point prefers the branch's private write overlay; if the branch has not touched that page, it sees the version visible at the fork point (the parent's at-or-before-base image).
Because the parent history below the fork is shared and the overlay holds only what the branch has diverged, creating a branch copies no pages and adds no storage until the branch's first write. This is implemented at the storage seam as BranchStorage (crates/storage/src/branch.rs), not as an engine special-case — the engine just opens a Database over the composed branch view.
LSNs stay continuous across the seam
The overlay assigns its own local LSNs from 1; the branch presents them shifted by the fork LSN, so the engine above sees one gap-free stream — the parent's 1..=base followed by the branch's base+1... Branching never breaks the strictly-monotonic, never-reused LSN invariant.
Write isolation
Branch writes are private by construction. A branch's append_wal and page writes land only in its own overlay, which owns the branch's commit log and fence token:
- MUST a branch's writes never touch the base or any sibling branch — copy-on-write produces new layers only; a parent's layers are never mutated by a child write.
- MUST the base and siblings are equally invisible to each other — the base never observes a branch's write, and a write to one branch never fences another. Each branch is an independent single-writer database with its own CAS epoch.
Both directions are isolated
Isolation is symmetric: the branch cannot see uncommitted-on-the-base writes that happen after the fork, and the base cannot see the branch's writes at all. Two branches off one base diverge independently and never collide.
Create a branch (embedded)
From the embedded Bun client, db.branch(name) forks the database at its current committed LSN and returns a new Database bound to the branch. The returned handle is independent — close it when you are done (or let using dispose it).
import { open } from "@twilldb/bun";
using db = open("file://./app.db");
db.exec(`CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT)`);
db.query("INSERT INTO notes VALUES (?, ?)", [1, "base"]);
// Fork at the base's current committed LSN — copies no pages.
using preview = db.branch("preview");
preview.query("INSERT INTO notes VALUES (?, ?)", [2, "branch-only"]);
preview.query("SELECT id, body FROM notes"); // sees row 1 (shared) + row 2 (private)
db.query("SELECT id, body FROM notes"); // sees only row 1 — the base never saw row 2
The fork is taken at db.lastLsn — the commit LSN of the last commit on that connection — so the branch starts from exactly the committed state you can observe at branch time. The returned Database behaves like any other: run SQL, prepare statements, open transactions, all against the branch's isolated overlay.
Close branches you open
A branch handle owns engine resources just like the base handle. Prefer using so it is released deterministically at scope exit; otherwise call .close() when the branch is no longer needed.
Rules and limits
Branching has a deliberately narrow contract. Violations return NULL + an error rather than guessing:
- MUST a branch is taken at the connection's current committed LSN. Uncommitted work on the base is not part of the fork.
- MUST NOT branch a branch — branch-of-branch is rejected (NULL + error). Fork from the base.
- MUST NOT branch inside an active transaction — branching mid-transaction is rejected (NULL + error). Commit or roll back first, then branch.
- SHOULD treat each branch as its own single-writer database: it gets its own writer lane and fence, so contention on one branch never serializes against another.
Rejections surface as NULL + error
In the Bun wrapper, an illegal branch call throws an EngineError carrying the engine status; over the C ABI, engine_branch returns a null handle. Check for it rather than assuming success.
Backend-agnostic by design
Branching lives at the storage seam, so the same copy-on-write semantics serve every backend with no per-backend branch logic. Only the overlay's physical home differs:
| Backend | Connection | Branch overlay |
|---|---|---|
LocalFileStorage | file:// | a sibling file alongside the base |
ObjectStorage | s3:// / r2:// / gs:// | a child key-prefix under the base |
Parent and overlay are both dyn Storage, composed by BranchStorage, so the engine and the embedded API are identical regardless of where the bytes live.
Use cases
- MAY many tools, one base. Provision one seed database and branch it once per tool; each tool diverges only as it writes, so storage stays near the seed size until tools actually mutate data — branching is the lever that makes "many small databases" affordable.
- MAY agent memory. An agent branches its memory to explore a line of work in isolation, then keeps or discards the branch without touching the base. Because the in-core vector index rides the same WAL as the rows, branching the database branches the vector index too — the agent forks its embeddings at near-zero cost.
- MAY preview & test. Fork the live data, run a migration or a risky change against the branch, verify, and throw the branch away — the base is never at risk.