SQL over sealed Parquet segments plus a live tip, in DuckDB's dialect, with exact integer arithmetic that refuses rather than wraps. The analytical engine inside nuthatch since 4.1.0, where DuckDB used to be: byte-identical to DuckDB on every authored view of the nests it has replaced it on, faster than it on the warm view benchmark, slower than it per statement in production, in one Rust binary with no C++ in it.
Status, 2026-10-02: the migration is done. nuthatch 4.1.0 ships with Burrmill as its only engine,
DuckDB is out of its tree and its lockfile, and all six production nests run on it. Gate 1 of
RFC-0044 Amendment 2 was taken as passed on 2026-09-26: every
authored view of a real production nest byte-identical, the set at 0.65x DuckDB's time, the memory
gate as nuthatch would run it, the larger build footprint accepted as the price of taking DuckDB's
C++ out of nuthatch. Phase 2, the engine trait, shadow mode and the cutover, ran in nuthatch from
2026-09-26 to 2026-10-02; its tick lists are in
docs/research/replacing-duckdb/migration.md. What a
week of production then showed is under where it loses.
The measurements behind every number here are in docs/progress-log.md,
newest first, with transcripts under docs/bench/. nuthatch pins a revision of this
repo in its Cargo.toml, so a change here reaches a nest only when that pin moves.
Since then, to 2026-11-11: day pruning through a join's range condition (#68), exact SUM and AVG
over DOUBLE (#77), the budgeted batch at 2,048 rows rather than 128 (#112), a bound on computed
values (#122), the engine in a browser tab (#106), untrusted CSV ingest and the demo page (#128), and
CI with a supply-chain check (#35, #206). RFC-0046, partial state over sealed history, is a design
with nothing built. The progress log has each with its figures.
In a browser tab, with nothing to install: burrmill-demo.vercel.app.
Drop a CSV, JSON or Parquet file and query it as table t. The file is read and queried inside the tab:
typing a statement and pressing Run here sends nothing anywhere. Asking a model to write the SQL is
optional, needs your own key or a local model, and sends only what docs/browser.md
lists. The page is demo/ in this repo.
Over chain data: Burrmill is what answers nuthatch sql, so the
nuthatch quickstart is the way to run it over sealed
segments. There is no standalone command-line tool; as a library it is burrmill::Engine.
Five crates. burrmill is the engine and is what nuthatch embeds; burrmill-ingest turns an
untrusted CSV into Arrow under counted budgets, with no engine dependency; burrmill-wasm puts ingest
and the engine in a browser tab for the demo/ page; burrmill-size links them for the wasm size
probes; burrmill-bench holds the oracles and harnesses. fuzz/ fuzzes ingest's CSV reader.
The burrmill crate has two layers.
The engine (burrmill::Engine, feature datafusion) is DataFusion 55 as the host planner and
executor, with Burrmill owning the parts DataFusion gets wrong for an indexer:
CheckedArithmetic: every sum, product and cast in a plan is rewritten to refuse on overflow instead of wrapping, exact through CTEs, joins and windows,TRY_CASTtainted and its sums made exact through unions and negation (tests/df_checked.rs).- DuckDB's dialect: authored views are DuckDB SQL and stay that way. Integer literals are fitted
as DuckDB fits them (
UBIGINT - 1staysUBIGINT), casts round as DuckDB's do (half to even from a float, half away from a decimal),DECIMALtoDOUBLEreproduces two DuckDB roundings that arrow does correctly,HUGEINTisDECIMAL(38,0),list_reduceis DuckDB's, list comprehensions andIS DISTINCT FROMparse as DuckDB parses them, and identifiers resolve without case. - Plan shapes DataFusion lacks: a range join for
x >= start AND x <= until(DataFusion runs it as a nested loop), top-per-group, a distinct split, shared repeated subqueries, DuckDB'sMoveConstants, correlated subqueries decorrelated by key, and repartitions removed where the scans beneath read under 4 MiB. FoldSubstitution: where a plan contains the signed-unionGROUP BYan indexer's balance rebuilds are made of, the owned operator below is swapped in, provably equal, visible inEXPLAIN. Under a budget its aggregation is charged to the pool; under a budget with a spill directory it is not swapped in, since DataFusion's aggregate can spill where the fold could only refuse.- A catalogue that knows the nest and nothing else:
Engine::open_nestreads the seal manifest, offers_decand_overflowbeside every wide column as nuthatch does, and registers no table factory and no file function. An unconfigured DataFusionSessionContextwill read/etc/hosts.
The operator (burrmill::Burrmill, the default build) is the vectorised partitioned fold that
the RFC began with: arrow for the format and kernels, parquet-rs for decode, checked i128
arithmetic, eight threads per query, a FIFO admission gate. It runs alone for the shapes it admits
and inside the engine's plans for everything else.
The parser role (burrmill::inspect::reach, default build) is nuthatch's allowlist and
reachability rules on sqlparser's AST, failing closed.
Reference. docs/sql.md lists the DuckDB forms Engine admits and the ones it
refuses by name, with what to write instead; docs/browser.md describes the tab.
llms.txt points a coding agent at both. Engine::open_segments and open_nest read
<table>-<hash>.parquet: the text before the last hyphen is the table, and a .parquet file with no
hyphen is skipped.
Faster. Measured 2026-09-24 and 25 on a 32-core ThinkPad against a 1,925-segment copy of the
graph-allocations nest that Lodestar reads in production, DuckDB set up exactly as nuthatch sets it
up, every view's whole output compared as a multiset of nuthatch-encoded rows before any timing was
printed (burrmill-bench engine-views, docs/bench/phase1b-thinkpad.txt,
docs/bench/rangejoin-thinkpad.txt):
| DuckDB | Burrmill | |
|---|---|---|
| authored views byte-identical | 22 of 22 | |
| time-weighted over the 22 | 39.6 s | 0.65x |
| views within 1.5x of DuckDB | 22 of 22 | |
| worst view | lodestar_disputes, 8 rows, 0.80x |
Re-run on 2026-09-27 with everything since in: 22 of 22 identical, 0.63x, 22 of 22 within 1.5x
(docs/bench/engine-views-thinkpad-2026-09-27.txt). It was not always so: on 2026-09-24 the same
harness measured 1.54x, with stock DataFusion at 1.81x on the same nest, and the owned plan shapes
above are what closed it. An earlier 0.71x was measured on a 38,428-segment copy where DuckDB pays
per file; the compacted layout is the fairer test and is the one quoted.
Serving the 12 views that were portable on 2026-09-24 to 1, 4, 16 and 32 concurrent clients
(burrmill-bench serve-views, docs/bench/serve-views-thinkpad.txt): 14.7 against 7.2 qps at
one client and 32.9 against 15.1 at 32, worst p99 1,350 ms against 7,424, fairness 0.90 against
0.00, and 3.9 GB of process memory against 15.4, all at 32 clients with ulimit -n raised, since
DuckDB fails outright at the default. Not re-run over the 22.
In production the sign is the other way. Those are whole views, warm, on a bench harness.
Through nuthatch's /sql on the allocations nest's real dashboard statements (sql_sweep_over_a_nest
on a copy, 2026-10-01), Burrmill at two sessions of 2 GB and eight threads served 11, 26 and 28 qps at
1, 4 and 8 clients where DuckDB at four sessions of 256 MB served 29, 112 and 123: about 2.5x
DuckDB's time a statement, p99 about 620 ms against 128 to 184. On the QoS nest, Lodestar's eight
one-day statements took 3.2 to 4.1 s against DuckDB's 0.6 to 0.7, because a date range arriving
through a join was not pushed to the scan and Burrmill read every segment where DuckDB read the
day's. RangeBounds (#68, 2026-10-04) now bounds the probe scan by the join's build-side extremes,
and on a QoS copy the kittiwake one-day statements went from 7 to 11 s to 1.1 to 1.6 s, 47 of 5.32K
row groups read. Byte-identical throughout, and the engine's memory limit holds; it is the speed
that nuthatch's README now quotes, and the swap was not made for speed.
Re-measured on 2026-11-11, in process. Nuthatch no longer carries DuckDB, so the /sql sweep
cannot be repeated. These are engine-sql over the ThinkPad's gate copies: one client, warm medians
of five, DuckDB bound as nuthatch bound it and at eight threads, the box under a load average of 60
to 110 (docs/bench/host-scan-statements-thinkpad-2026-11-11.txt). Over the allocations dashboard's
75 statements (6,753 segments, Burrmill at 2 GB and eight threads) Burrmill's median is 555 ms
against DuckDB's 1,616, the sum 102 s against 243, and it is the faster on 65. Over the QoS day
set's nine (14,913 segments, four threads) Burrmill at 2 GB answers all nine, the six heavy ones in
2.7 to 5.4 s against DuckDB's 4.1 to 5.4. At the nest's own 704 MB, with no spill directory, it
refuses four of the nine, and the two heavy ones it answers take 6.2 and 7.1 s against DuckDB's 4.0
and 4.7; before #269 charged a scan for the segments it holds, it refused two to four. Answers
match but for float sums in the last digit, which DuckDB does not repeat between runs either.
Exact. Integer overflow returns an error, never a wrapped number, on both layers. The owned fold
refuses when an intermediate partial sum leaves i128; the engine's CheckedArithmetic makes the
same promise on DataFusion's plans, where stock DataFusion silently wraps
(SELECT 10000000000 * 10000000000 is 7766279631452241920, and as of August 2026 there is no core
flag to stop it: #17539, #14771, #20034). DuckDB errors on HUGEINT overflow but not watertight: in
the 1.5 line a SUM over two files on two threads returns i128::MIN for a true MAX + 1
(duckdb#24081, fixed on main after 1.5.5;
burrmill-bench duckdb-gaps reproduces it). The differential fuzzer also found a DuckDB wrong answer
nobody had reported, a text comparison against a TIMESTAMPTZ cast dropping every row once the
session's zone has been consulted, written up in
docs/upstream/ and not yet filed.
Closed. The owned path resolves table names against a positive allowlist and registers no
file-I/O function at all: read_parquet('/etc/passwd') has nowhere in the grammar to parse to. The
engine refuses anything but SELECT at the surface, registers no table factories, and had
input_file_name() removed after a path leak (6.2). reach agrees with nuthatch's own walk over
DuckDB on 38 of 46 security-corpus statements and is stricter on the other 8, never looser
(burrmill-bench reach-parity).
The confidence comes from running both engines on the same statements, not from reading either.
burrmill-bench fuzz: SQL drawn from a typed grammar over awkward data, run on DuckDB and on the engine, answers compared as multisets.CASES=<n> SEED=<n>; a difference is reported with the seed that reproduces it (SEED=<n> CASES=1 PRINT=1), andSQL=<query>runs one statement. It found nine faults in Burrmill on its first day, three of them silent wrong answers, then a DataFusion wrong answer on subqueries used as predicates, then the two DuckDB findings above. The last runs: 25,000 cases on five seeds, 8,000 on four, 6,000 on three, each with no differing answer (docs/bench/fuzz.txt).- Parity harnesses, all against DuckDB:
dialect-parity273/273 statements,encode-parity17/17 (nuthatch's JSON, byte for byte),error-parity14/14 (nuthatch's error classes),reach-parity38/46 identical and 8 stricter,rewrite-parity <nest> <views-dir>for a rewritten view against its original. - The memory gate:
examples/hosted_fold, the fold in a binary shaped like nuthatch after the swap, 241 to 245 MB peak RSS at 989,690 groups against the 256 MB gate, DuckDB at 572 to 598 MB on the same fold (docs/bench/memory-gate-thinkpad-2026-09-25.txt). The bench binary reads 20 MB higher because it links DuckDB and the umbrella crate as oracles, and their code pages count. - The generated fold corpus (
burrmill-bench gen,tests/generated_folds.rs) and the.sltfiles, which run against Burrmill incargo testand against DuckDB withburrmill-bench slt.
Said plainly, because a README that implies otherwise is the thing this project is against.
- The binary is bigger and the build is not faster. On a nuthatch-shaped consumer, same machine,
32 jobs (
docs/research/replacing-duckdb/probes/footprint6/results/burrmill.txt): querying test binary 495 MB against DuckDB's 162, release binary 112 MB against 41,target/4.76 GB against 3.27, clean test build 84 s against 77, incremental 1.8 s against 1.1. DuckDB is one C++ archive compiled once; DataFusion is dozens of crates whose generic operators are instantiated per type into every test binary. The footprint is accepted; the build time is burrmill#7. - Small queries. An eight-row view runs eighteen DataFusion operators.
lodestar_disputesis 0.80x DuckDB after the small-input rule, and the worst ratio on the nest. HUGEINTstops at 38 digits.DECIMAL(38,0)reaches 10^38 - 1 where DuckDB's reaches 2^127 - 1, and widening it would change the type of every HUGEINT column nuthatch plans. ACASTof a value between refuses (#42), and so does aTRY_CASTwhose value is read, from text (#44) or from a DOUBLE or FLOAT (#63), rather than answer a NULL DuckDB would not give. ItsIS [NOT] NULLand itsSUManswer as DuckDB does, the sum exactly while the total fits 38 digits. No real nest has produced one.- What DuckDB computes and Burrmill refuses, by design: a
SUMover aTRY_CASTthat DuckDB answers by dropping what did not fit. The fuzzer counts these separately and they are allowed. - What Burrmill computes and DuckDB refuses:
TIMESTAMPTZ + INTERVALand aTIMESTAMPTZtoDATEcast, which nuthatch's DuckDB, built without ICU, will not do. - Slower per statement in production, as measured on 2026-10-01 and 02: about 2.5x DuckDB's time
through
/sqlon the allocations nest, and 4.5 s against 0.7 for the QoS nest's eight one-day statements. The QoS cause, a day range through a join side not reaching the scan, is fixed byRangeBounds(#68) for a collect-left join that drops unmatched probe rows; a partitioned join, an outer probe side or a float comparison still reads every segment. Neither figure can be taken again as it was, since nuthatch has no DuckDB to serve beside. In process on 2026-11-11 Burrmill was the faster on the allocations statements and level with DuckDB on the QoS day set at 2 GB (above). What QoS is owed now is memory rather than pruning: at 704 MB without a spill directory it refuses four of the nine day statements and runs the heavy ones it answers at about 1.5 times DuckDB's time, and no session keeps a large table's footers between statements, since the nest's take 341 MB and a 704 MB budget's cache holds 88 (RFC-0047 §3). - More memory per session. The allocations nest's dashboard refuses 12 or 13 of its statements at 1 GB and 18 at 768 MB, so nuthatch runs it at two sessions of 2 GB and eight threads where DuckDB had four of 256 MB. The engine's limit holds; what glibc then kept of a whole-day statement's freed pages took the QoS nest to 7 GB resident, and nuthatch answers that with jemalloc on Linux (nuthatch#1628), not with a change here.
- Planning cost on deep views. A query that plans through about thirty views takes 42 to 47 s in a
release build; nuthatch's
graphCI job went from 13 minutes on DuckDB to 45 on a runner, and its router tests carry a ten-times allowance undercfg!(test). The time sits in DataFusion's physical planning (equivalence::properties,Statistics), and is the engine's to recover. - Spilling joins. A whole QoS view at 512 MB still spills past 2 GB or runs to the 600 s wall.
- The owned operator alone runs 0 of 65 real statements. Every fold sub-plan in the workload admits (8/8), but each sits inside a CTE or a join, so whole statements run on the engine.
- No redb
HotTip. The hot/cold seam holds under concurrent seal (COR-1), but the only tip is in-memory (roadmap 3.4). - DataFusion joins do not yield to cancellation (apache/datafusion#19358). A statement with a join cannot promise the one-morsel cancellation bound; the mitigation is a per-query timeout at nuthatch's 30 s guard.
- 4.2c is still owed: the earliest
viewsbench gave DuckDB noparquet_metadata_cache, worth about 9% on the curation fold. The Gate 1 harnesses set DuckDB up as nuthatch does; the old synthetic sweep has not been re-run with it. - Seven of the allocations nest's views had to be rewritten to run here (
ASOF,LATERALand list lambdas; of theseLATERALand lambdas answer now andASOFstill fails as a syntax error), each compared whole against its original on DuckDB before the engine changed (23/23,lodestar_indexer_ledger1,678,525 rows). They are on the nest's main (graph-allocations-nest#28). Two more (lodestar_delegator_stakes,lodestar_delegators) run unchanged.
crates/burrmill the library. Default build: arrow, parquet, rustc-hash, rayon,
hashbrown, sqlparser. Feature `datafusion` adds the engine: about
sixteen datafusion-* component crates pinned =55.2.0, tokio,
object_store. No DuckDB. No C++. `src/df/` holds the engine and its
rules, one file each.
crates/burrmill/examples hosted_fold: the memory gate in a Burrmill-only binary.
crates/burrmill-ingest untrusted CSV to Arrow under counted budgets. No engine, no C, builds
for wasm32.
crates/burrmill-wasm the browser tab's package: ingest, the engine, and the checks on what
a model is sent and answers, in schema, aggregates and pseudonymised
modes. The page does the HTTP.
crates/burrmill-size the wasm size probes' binaries (docs/bench/*-wasm.txt).
crates/burrmill-bench publish = false. Both oracles (DuckDB, the umbrella DataFusion) live
here, so neither can reach the shipped graph. Note it lends the crate
DataFusion features by unification; test a consumer on burrmill alone.
docs/progress-log.md every measurement, newest first
docs/research/replacing-duckdb the investigations, plan.md, and migration.md with the tick lists
docs/upstream bugs found in DuckDB, DataFusion and nuthatch, drafted for filing
docs/bench transcripts the numbers above are quoted from
docs/rfc RFC-0044 to RFC-0047
demo/ the browser page over burrmill-wasm, offline once loaded
fuzz/ cargo-fuzz targets for burrmill-ingest, in their own workspace
corpus/ ingest's CSV corpus
# Every authored view of a real nest on both engines: parity as a multiset of nuthatch JSON rows,
# then warm timings. Read-only; nothing is written to the nest.
cargo run -p burrmill-bench --release -- engine-views /path/to/nest
# One view's plan on both engines, or one SQL file.
cargo run -p burrmill-bench --release -- engine-analyze /path/to/nest lodestar_epochs
cargo run -p burrmill-bench --release -- engine-sql /path/to/nest query.sql
# A rewritten view against its original, both on DuckDB, then Burrmill against DuckDB.
cargo run -p burrmill-bench --release -- rewrite-parity /path/to/nest /path/to/views
# All 22 views served to 1, 4, 16 and 32 clients on both engines.
cargo run -p burrmill-bench --release -- serve-views /path/to/nest
# The memory gate, as nuthatch would run it (Linux, for the /proc split).
cargo run -p burrmill --release --example hosted_fold --features datafusion /path/to/nest/segments
# Stock DataFusion on the same nest, the control.
cargo run -p burrmill-bench --release -- df-views /path/to/nestSet TZ=UTC in the environment before any of these, as nuthatch's hosts do; the harnesses take
DuckDB's zone from it and never SET TimeZone, because doing so switches on the DuckDB bug above.
# Differential fuzzing against DuckDB. A difference prints the seed that reproduces it.
CASES=5000 SEED=7 cargo run -p burrmill-bench --release -- fuzz
SEED=7 CASES=1 PRINT=1 cargo run -p burrmill-bench --release -- fuzz
SQL="SELECT substr('10', -3, 2)" cargo run -p burrmill-bench --release -- fuzz
# The parity harnesses.
cargo run -p burrmill-bench --release -- dialect-parity
cargo run -p burrmill-bench --release -- encode-parity
cargo run -p burrmill-bench --release -- error-parity
cargo run -p burrmill-bench --release -- reach-parity
# Generated fold cases against DuckDB, and the .slt corpus pointed at DuckDB.
CASES=3000 cargo run -p burrmill-bench --release -- gen
cargo run -p burrmill-bench --release -- slt
# Where the two engines' casts disagree, printed; DuckDB's HUGEINT wrap, reproduced.
cargo run -p burrmill-bench --release -- cast
cargo run -p burrmill-bench --release -- duckdb-gaps
# The seal-layout canary against a real nest; more generated cases; one case again.
BURRMILL_NEST=/path/to/nest/segments cargo test --test seal_layout -- --nocapture
BURRMILL_CASES=5000 cargo test --test generated_folds
BURRMILL_SEED=1234 cargo test --test generated_foldscargo test runs the fast half of this on every invocation.
The owned operator's original gate, "at most 1.0x DuckDB at exact parity under 256 MB peak RSS at eight threads", still runs and still passes: 0.38 to 0.87x across fourteen configurations, 199 to 218 MB at 989,690 groups depending on the stage.
ROWS=2000000 SEGMENTS=100 REPEATS=5 cargo run -p burrmill-bench --release
ROWS=2000000 SEGMENTS=100 ADDRS=1000000 REPEATS=5 cargo run -p burrmill-bench --release
BREAK_PARITY=1 cargo run -p burrmill-bench --release # the guard must refuse; no RESULT line
ORDER=burrmill_first cargo run -p burrmill-bench --release # page-cache order is a confound
cargo run -p burrmill-bench --release -- inspect /path/to/nest/segments
cargo run -p burrmill-bench --release -- explain /path/to/nest/segments
cargo run -p burrmill-bench --release -- nest /path/to/nest/segments <table-prefix>
cargo run -p burrmill-bench --release -- serve <fixture-dir>A real-nest ratio that is mostly fixed cost is not printed as a number; the field reads
UNSAFE_fixed_duck=NNpct instead (docs/bench/method.md).
MIT OR Apache-2.0.