August 7, 2026

Twenty-Five Million Rows in Five Seconds, and How to Check

Benchmarks in this industry are mostly untrustworthy, and usually for the same reason: the person running them chose the shape of the work. So let us be precise about what this is and what it is not.

This is not a comparison against dbt. dbt is not an execution engine — it generates SQL and hands it to your warehouse — so “Interlace versus dbt” would be measuring DuckDB against whatever you pointed dbt at, dressed up as a tool comparison. That number would tell you nothing.

This is a capability demonstration: a real DAG doing real work, on hardware you can buy, with every command written down. The point is not that a number is small. The point is that you can run the same thing in the next ten minutes and get your own.

The shape of the work

The examples/benchmark project generates 25 million synthetic events in-engine — nothing to download, fully deterministic — and pushes them through a deliberately awkward fan-out:

events (25M rows) ── enriched (ephemeral: inlined into every consumer)
                      ├─ by_user ────┬─ user_ltv       (Python, Arrow batches, merge)
                      │              └─ user_history   (scd — Type 2 history)
                      ├─ by_product ─┬─ top_products   (view)
                      │              └─ product_catalog (full_merge, composite key)
                      ├─ by_device
                      └─ by_day
events ───────────── daily_revenue (incremental_by_time, 1d grain)
                      ├─ revenue_report (parquet file)
                      └─ daily_feed     (append → external DuckDB, reverse ETL)

Two details make this harder than it looks. enriched is ephemeral, so it is inlined as a CTE into every consumer rather than materialised once — each of the four by_* branches scans the full 25 million rows independently. And the branches share no edges, so they are eligible to build concurrently.

Between them, twelve models exercise every strategyreplace, incremental_by_time, merge, full_merge, scd, append — across virtual, ephemeral, view, file and an external table. There is a Python model in the hot path, not bolted on at the end.

The numbers

Machine: AMD Ryzen 9 9955HX3D (16 cores, 32 threads), 60 GB RAM, Linux. Python 3.12.3, DuckDB 1.5.4, PyArrow 23.0. A DuckLake warehouse on local disk.

Three consecutive cold builds — full teardown of the warehouse between each:

run 1: 5.19s
run 2: 5.10s
run 3: 4.98s

With CPU accounting on the same cold build:

Wall clock5.11s
CPU (user + system)13.83s
CPU utilisation270%
Peak resident memory1.84 GB

The gap between 13.83s of CPU and 5.11s of wall is the whole point of scheduling the true DAG: independent branches build at the same time, and DuckDB parallelises inside each query. The memory figure matters more than the time one — 25 million rows moved through a Python model and five strategies without the process exceeding two gigabytes, because data crosses as streamed Arrow RecordBatches rather than being materialised into pandas at each boundary.

Per-model, from the build table:

 Model             Output    Strategy              Rows      Time
 events            virtual   replace         25,000,000     3.75s
 by_user           virtual   replace            100,000     0.46s
 top_products      view      replace                  —     0.37s
 product_catalog   virtual   full_merge          15,000     0.35s
 daily_feed        table     append                  29     0.31s
 daily_revenue     virtual   incremental_by_time     29     0.29s
 by_product        virtual   replace             15,000     0.21s
 user_ltv          virtual   merge              100,000     0.16s
 by_device         virtual   replace                  4     0.11s
 user_history      virtual   scd                100,000     0.09s
 by_day            virtual   replace                 30     0.07s
 revenue_report    file      replace                 29     0.07s

Generating the 25 million rows is 3.75s of the 5.11s. Everything downstream — five strategies, a Python model, a Parquet write and a delivery into an external database — is the remaining 1.4 seconds of wall time.

Do not take the row counts on trust. Ask the warehouse:

interlace query "SELECT count(*) FROM events"
# 25000000

The parts that are not about speed

Raw throughput is the least interesting thing here, because it is mostly DuckDB’s. The interesting behaviour is what happens on the second run.

A repeated incremental window does nothing at all. daily_revenue is incremental_by_time at a one-day grain, and completed intervals are recorded in a ledger:

interlace run --select daily_revenue --start 2026-06-01 --end 2026-07-01
# Ran 0 model(s) (0 task(s)); promoted 1 to 'prod'.     0.35s

Zero tasks — not a fast rebuild, no rebuild. Reprocessing is a separate verb, so asking for it is deliberate rather than accidental:

interlace restate --select daily_revenue --start 2026-06-08 --end 2026-06-15
# Checks: 7/7 passed
# Restated 1 model(s) (1 task(s)); promoted 1 to 'prod'.   0.67s

A one-line change rebuilds one branch. Add a column to by_device, one of four branches hanging off a 25-million-row ephemeral scan:

sed -i 's/avg_ticket/avg_ticket, min(amount) AS min_ticket/' models/by_device.sql
interlace plan
 Model       Change     Category   Build
 by_device   modified   breaking   rebuild

One model, and apply then refuses to run it:

plan has breaking changes (by_device); re-run with --force to proceed

That refusal is the feature. Adding a column changes the shape of what consumers see, so it is classified breaking and the build stops before touching the warehouse — the classification is a gate, not a report. Forcing it through rebuilds only what changed:

interlace apply --force
# by_device   virtual   replace   +4   0.10s
# Built 1 model(s); promoted 13 to 'prod'.      0.48s wall

0.48s against 5.11s for the full build, and the other eleven models were never touched, because their fingerprints did not move.

Run it yourself

git clone https://github.com/interlace-sh/interlace
cd interlace/examples/benchmark
pip install interlaced

time interlace apply
interlace query "SELECT count(*) FROM events"

To make the machine work harder, raise range(25000000) in models/events.sql. At 100 million the DAG shape does not change — each branch simply scans four times as much through the inlined CTE.

If your numbers differ from ours, that is useful information and we would like to see them. Different hardware, a different filesystem, a spinning disk, a container with two cores — all of those will move these figures, and none of them are hidden behind a marketing chart.

What this does not show

A single machine. Every number above comes from one process on one box, and that is the case Interlace is built for. If your warehouse does not fit on one machine, this benchmark is not evidence about your workload, and we would rather say so than let a chart imply otherwise.

It also says nothing about the engines we have not proven. These runs are DuckDB and DuckLake, which are tested in CI. Spark is beta; Redshift, Snowflake, BigQuery and MotherDuck are alpha — dialect-correct and unit-tested, but not yet run against a live account.


The DAG above is in examples/benchmark. Start with the introduction, or install it:

pip install interlaced