Skip to content
System architecture

DuckDB 2.0 alpha: where the speed-ups come from, and what to test before it ships

Why DuckDB 2.0 reads S3 up to 3x faster, runs recursive queries in a tenth of a second, and where VARIANT is still slower than JSON.

K8 min read
A duck picking up speed fits a piece about 2.0 being faster, and it hints at DuckDB without using its logo.

DuckDB 2.0, codenamed Cyanoptera, went into feature freeze on 2 September 2026, and the team is projecting the release for the second half of October (DuckDB). The alpha is out now, and the first benchmarks against 1.5.5 are large: up to 3× on remote Parquet, roughly 6× for semi-structured data stored as VARIANT instead of JSON text, and recursive queries that went from seconds to a tenth of a second (MotherDuck).

Numbers like that only help you if you know which of your queries they apply to. This article explains where each speed-up comes from, so you can tell whether your workload will see it, shows the one benchmark where 2.0 is slower, and lists what to check before you point production at it.

Install the alpha next to your current version

The alpha installs into its own place, so it does not replace the DuckDB your application uses. The CLI and the Python client are available now; other clients, including Node, will move to 2.0 gradually (DuckDB).

Terminal
$ curl https://install.duckdb.org | DUCKDB_VERSION=alpha bash$ ~/.duckdb/cli/latest/duckdb -c "SELECT version() AS version;"
Terminal
$ pip install duckdb --pre --upgrade$ python3 -c "import duckdb; print(duckdb.version())"

If your service talks to DuckDB from Node, test with the CLI against the same files the service reads. The engine is what changed; the client binding is a thin layer over it.

Remote reads overlap the network and the CPU

In 1.5.5, each worker thread alternates between downloading a chunk of a file and decoding it. While it waits on the network, its CPU core sits idle, and you can never have more downloads in flight than threads. In 2.0, a separate download pool fetches row groups ahead of time and buffers them, and the worker threads only decode (MotherDuck). The asynchronous I/O covers Parquet and CSV reads, DuckDB's own file format, and Parquet writes (DuckDB).

S3Parquet row groupsDownload poolreads aheadBuffered row groupsWorker threadsdecode onlymany requests in flightdecode
In 2.0 the network and the CPU are busy at the same time.

That explains the shape of the results. Large files with many row groups gain the most, because there is plenty to read ahead. Thirty tiny files barely change, because the time goes to per-file round trips (the footer first, then the data), which reading ahead cannot remove.

Big remote files gain the most; tiny ones barely moveSpeed-up of DuckDB 2.0 alpha over 1.5.5, higher is better
23 Parquet files, 13.6 GB3×One 2.2 GB Parquet file2.4×One 1.7 GB CSV file2.1×30 tiny Parquet files1.1×

Source: MotherDuck, Why DuckDB 2.0 is faster (M5 laptop, S3 us-east-1)

Big remote files gain the most; tiny ones barely move
Speed-up
23 Parquet files, 13.6 GB3×
One 2.2 GB Parquet file2.4×
One 1.7 GB CSV file2.1×
30 tiny Parquet files1.1×
Read from S31.5.52.0 alpha
One 2.2 GB Parquet file, one column18.8 s7.7 s
23 Parquet files, 13.6 GB, one column11.8 s3.9 s
One 1.7 GB CSV file116 s55 s
30 Parquet files, about 1 MB each3.7 s3.3 s

The read-ahead is controlled by read_ahead_depth. The default, -1, sizes it from the thread count; 0 gives you the 1.5 behaviour back, which is the quickest way to tell whether a regression comes from this change.

SQL
SET read_ahead_depth = 0;  -- 1.5-style reads, for comparisonSET read_ahead_depth = -1; -- the 2.0 default

Keep evicted remote data on local disk

The external file cache now spills to the temp directory instead of dropping blocks it has no memory for. With a 300 MB memory limit, MotherDuck's second read of an 854 MB Parquet file on S3 fell from 23.9 s to 0.35 s, because the blocks came off local disk instead of being downloaded again.

SQL
SET external_file_cache_spill = true;

If you run DuckDB in a small container that reads the same remote files repeatedly, this setting may matter more than anything else in the release. Make sure the temp directory sits on a disk with room for it.

Recursive CTEs stop rescanning the table

In 1.5.5, every round of a recursive CTE re-reads the whole table it joins against, so the cost is the number of rounds times the size of the table. In 2.0, the table is read once, a lookup on the join column is built once, and each round only looks up the rows the previous round found (MotherDuck). The engine behind it was rewritten (DuckDB).

DuckDB's own reachability benchmark over one million edges went from 4.90 s in 1.5.4 to 0.12 s, about 40×. MotherDuck walked the ancestry of a 20,000-commit repository: 1.8 to 16 seconds across runs on 1.5.5, 0.10 s on every run with the alpha.

ancestors.sqlSQL
WITH RECURSIVE ancestors(id) AS (  SELECT max(id) FROM commits  -- HEAD  UNION  SELECT p.parent_id  FROM ancestors a  JOIN commit_parents p ON p.commit_id = a.id)SELECT count(*) FROM ancestors;

The gain grows with depth. A shallow hierarchy, such as an org chart a few levels deep, runs few rounds and has little to save. A deep one, like commit history, a bill of materials or a permission graph, is where you will see it.

Carry a value through the recursion with USING KEY

USING KEY keeps one row per key and lets you aggregate over the rounds, so iterative algorithms can be written in SQL. The example from the DuckDB preview averages b per a as the recursion counts down:

SQL
WITH RECURSIVE tbl(a, b) USING KEY (a, avg(b)) AS (    SELECT 1, 5    UNION ALL    SELECT a, b - 1 FROM tbl WHERE b > 0)TABLE tbl;

It returns one row, a = 1, b = 2.5. Reach for it when the recursion carries a cost or a depth, such as a shortest path, rather than just a set of ids.

VARIANT stores JSON as columns you can skip

VARIANT arrived in 1.5; 2.0 is the release where it pays off. When DuckDB checkpoints, it looks for fields that appear consistently with the same type and stores them as real sub-columns, a process called shredding. Rare or mixed-type fields go into a binary remainder (DuckDB). A filter on payload.type then reads one compact column, and the filter is pushed into the scan.

MotherDuck stored five million events three ways (as a JSON string, as VARIANT, and as a normal table with one typed column per field) and ran three queries:

queries.sqlSQL
-- Q1: filter on two fieldsSELECT count(*) FROM evWHERE payload.type::VARCHAR = 'purchase'  AND payload.user.country::VARCHAR = 'FR'; -- Q2: sum a numeric field, groupedSELECT payload.user.country::VARCHAR AS country,       sum(payload.props.amount::DOUBLE) AS amountFROM evWHERE payload.type::VARCHAR = 'purchase'GROUP BY ALLORDER BY 1; -- Q3: look inside a listSELECT count(*) FROM evWHERE list_contains(payload.tags::VARCHAR[], 'c');
VARIANT nearly matches typed columns, until you open a listQuery time in milliseconds, lower is better
JSON stringVARIANT, 2.0 alphaTyped columns
0  ms500  ms1,000  ms1,500  ms2,000  msQ1 filterQ2 sum by countryQ3 list contains

Source: MotherDuck, Why DuckDB 2.0 is faster (5 million events)

VARIANT nearly matches typed columns, until you open a list
JSON stringVARIANT, 2.0 alphaTyped columns
Q1 filter366  ms63  ms52  ms
Q2 sum by country408  ms61  ms51  ms
Q3 list contains357  ms1,960  ms67  ms

On the field filters, VARIANT is about six times faster than the JSON string and within a few milliseconds of a hand-designed table. On 1.5.5 the same VARIANT queries took around five seconds each, so if you tried the type on 1.5 and gave up, try it again. It is also smaller on disk:

Stored asOn disk
JSON string224 MB
VARIANT, 2.0 alpha85 MB
Typed columns45 MB

Q3 is the exception. Casting a VARIANT list to VARCHAR[] costs about two seconds in this alpha, so the JSON string is roughly five times faster for that query. If a query you run often looks inside arrays, keep that field as JSON or promote it to its own column.

SQL
ALTER TABLE ev ADD COLUMN type VARCHAR;UPDATE ev SET type = payload.type::VARCHAR;

Promoting the fields you filter on most is still the fastest option. VARIANT is what you use for everything you have not promoted yet.

DuckDB can run as a server

The quack extension, DuckDB's own network protocol, becomes stable in 2.0. Any DuckDB process can serve its databases, and another DuckDB can attach to it and send queries with CONNECT; the query runs on the server and the results stream back (DuckDB).

server.sqlSQL
CALL quack_serve(token = 'my_token');
client.sqlSQL
ATTACH 'quack:server.example.com' AS qk (TOKEN 'my_token');CONNECT qk;SELECT count(*) FROM events;DISCONNECT;
Private networkAnalystDuckDB CLIServiceDuckDB clientDuckDBquack_serveParquet on S3PostgreSQLATTACH + CONNECTATTACH + CONNECTasync readspushdown
One process holds the data and the cache; clients send SQL to it.

This addresses a common limit of an embedded engine: one process per user, each with its own cache and its own copy of the work. It relies on DuckDB's existing multi-connection transactions (InfoQ). CONNECT also accepts a PostgreSQL or MySQL URL, and a new optimiser sends the SQL to that server instead of pulling whole tables across the network.

The preview shows a token and nothing else for authentication. Until the release documentation says more about TLS and access control, run a quack server on a private network, as in the diagram, not on a public address.

What will break

The DuckDB team lists only a few breaking changes so far and leaves the full list to the release announcement (DuckDB):

  • The default storage format becomes v2.0.0. This is the one to plan for: a database file written in the new format is a migration, not a version bump.
  • The lambda syntax transition is completed. Expect the deprecated form of lambdas in list functions to stop working, and check the release notes for the exact cut.
  • A new PEG parser replaces the PostgreSQL-derived one. It is designed to be compatible, but unusual SQL is exactly what a new parser trips on.
  • ICU is gone. Time zones, calendars and collations are now implemented natively in the icu extension.

Smaller additions worth knowing

Triggers arrive with BEFORE and AFTER, per-row and per-statement modes, and transition tables. INSERT, UPDATE, DELETE and COPY now work inside a CTE, which turns a staging-to-archive move into one atomic statement:

SQL
WITH moved AS MATERIALIZED (    DELETE FROM staging RETURNING *)INSERT INTO archive SELECT * FROM moved;

There are also nested schemas (finance.reports.q3), session variables (SET VARIABLE threshold = 100; then $threshold), json_set and its siblings for changing JSON in place, and an APPROX NEAREST join for top-k similarity search over embeddings.

What to test before 2.0 ships

  • Install the alpha CLI and run your slowest real queries against the same files: remote Parquet first, then anything recursive.
  • For a regression on remote reads, rerun with SET read_ahead_depth = 0; to see whether read-ahead is the cause.
  • Find queries that filter JSON fields and try them on a VARIANT copy of the table; check any that look inside arrays.
  • Turn on external_file_cache_spill if you read the same remote files under a tight memory limit.
  • Open a copy of each .duckdb file with the alpha, never the original.
  • Search your SQL for the old lambda syntax and for anything else the parser might read differently.
  • If a query errors, report it with a reproducible example in duckdb/duckdb while there is still time for a fix to land.

The remote-read and recursive-CTE gains need no change to your SQL, so they are free once you upgrade. The storage format is the part that is hard to undo; test that one first.

Found this useful? Share itXLinkedIn