Copy-on-Write SQLite Branches โ€ข Loadable Extension

A fresh copy of your database,
without copying it.

Testing against a production-sized SQLite database in CI goes one of two ways: copy the file for every test (26.2 s for 200 tests on a 125 MB seed), or share one database between tests (flaky, because the state is shared). forkdb gives each test its own branch instead: the same 200 tests take 2.82 s with nosync. A fork copies nothing, and a branch stores only the pages it changes.

$ pip install forkdb
$ npm install sqlite-forkdb

The npm package is sqlite-forkdb; the PyPI package is forkdb. v0.1.0 • Linux x86_64 • macOS arm64 • Windows x86_64

QUICKSTARTS

Pick your host

forkdb loads into the SQLite you already use as an extension. Each host below opens a branch as its own connection.

Python
$ pip install forkdb import forkdb host = forkdb.connect() host.execute("select forkdb_init(?)", ("/tmp/s.fk",)) host.execute("select forkdb_fork(?, 'main', 'a')", ("/tmp/s.fk",)) a = forkdb.open_branch(host, "/tmp/s.fk", "a")

Your Python's sqlite3 must support loading extensions, and the SQLite behind it must be 3.24 or later. On macOS that means Homebrew's Python (or any Python whose SQLite can load extensions); the macOS system Python cannot.

Node
$ npm install sqlite-forkdb const { DatabaseSync } = require('node:sqlite'); const forkdb = require('sqlite-forkdb'); const host = forkdb.load(new DatabaseSync(':memory:', { allowExtension: true })); host.prepare('select forkdb_init(?)').get('/tmp/s.fk'); host.prepare("select forkdb_fork(?, 'main', 'a')").get('/tmp/s.fk');

Tested on Node 24, with node:sqlite and better-sqlite3. better-sqlite3 needs SQLITE_USE_URI=1 set before it is required.

CLI
$ forkdb init ./app.fk $ forkdb fork ./app.fk feat-x $ forkdb list ./app.fk

The CLI is for inspecting a store that no application has open: the store file is locked while open. Prebuilt on the v0.1.0 release: Linux x86_64 (glibc 2.28 or later) and macOS arm64 (macOS 11 or later), with a SHA256SUMS file. There is no Windows CLI download.

The extension is also built on its own as forkdb.so, forkdb.dylib or forkdb.dll. The packages carry prebuilt libraries for Linux x86_64 (glibc 2.28 or later), macOS arm64 and Windows x86_64; elsewhere, build the extension and point FORKDB_EXTENSION_PATH at it.

MEASURED, NOT PROJECTED

What nosync saves

nosync is opt-in: ask for it when you create the store, with forkdb_init(path, 'main', 'nosync'). A nosync store skips every fsync, so it is for throwaway stores. It survives the death of the process; it is not safe against a power cut. These are median milliseconds per operation on CI runners, from scripts/bench-nosync.sh: durable first, then nosync.

Operation Linux (ext4) macOS (virtual machine) Windows (NTFS)
Commit (insert one row) 0.133 โ†’ 0.0152 ms (9x) 0.827 โ†’ 0.0115 ms (72x) 5.577 โ†’ 0.0237 ms (235x)
Fork and discard 0.448 โ†’ 0.0190 ms (24x) 0.304 โ†’ 0.0124 ms (25x) 21.834 โ†’ 0.0243 ms (900x)
Test fixture (fork, write, commit, close, discard) 0.680 โ†’ 0.1104 ms (6x) 0.453 โ†’ 0.0542 ms (8x) 27.842 โ†’ 0.0962 ms (290x)

The saving is a property of the disk, not of forkdb: each figure is one disk of one runner on one day. On tmpfs there is no saving, because an fsync costs nothing there. Run scripts/bench-nosync.sh with FK_BENCH_DIR on the disk you care about. For a small seed on a disk, a plain file copy per test is as good; branches win on large seeds and on tmpfs.

On a real test suite: in the repository's pytest example, one test's database costs 0.09 ms with nosync (get a private database holding the seed, update ten rows, commit, discard), at every seed size from 1,000 to 1,000,000 rows, because a fork copies no pages. The same 200 tests (100 reads, 60 committed writes, 40 uncommitted writes) on a 1,000,000-row, 125 MB seed take 2.82 s with forkdb and 26.2 s with a file copy per test; with fsync on, forkdb takes 4.61 s. One run on one Linux machine, Python 3.12, SQLite 3.45.

KNOWN LIMITS

Where the edges are

What is tested, and against what, is spelled out in the repository's README and KNOWN-LIMITS. The short version: