, 6 min read, duckdb, databases, sql
DuckDB 2.0 grew a server (and a one-way file format)
DuckDB 2.0 ships a client-server protocol, VARIANT and triggers. I ran the server, measured VARIANT, and checked which files older versions can no longer open.
, 6 min read, duckdb, databases, sql
DuckDB 2.0 ships a client-server protocol, VARIANT and triggers. I ran the server, measured VARIANT, and checked which files older versions can no longer open.
For most of its life, DuckDB's pitch was that there is no server. You pip install duckdb, point it at a file or a folder of Parquet, and get a fast analytics engine without a database process to run, monitor or get paged about. DuckDB 2.0.0, the LTS release that came out on October 21, 2026, keeps all of that.1 It also ships Quack, a client-server protocol, along with a CONNECT statement, a properly wired-up VARIANT type, triggers, async I/O and a CLI that notices when an AI agent is the one typing.
The version went from 1 to 2. I think the project moved further than that, so I installed it and tried the three parts I care about most: the server, VARIANT, and the new file format.
Quack first appeared in 1.5.3. In 2.0 the team calls it polished, with "substantial performance and stability improvements".1 Any DuckDB process can now serve its database:
-- the "server" process
INSTALL quack;
LOAD quack;
CALL quack_serve(token = 'my_token');and any other DuckDB can attach to it:
-- the "client" process
ATTACH 'quack:localhost' AS qk (TOKEN 'my_token');
CONNECT qk;By default the server only listens on localhost, and the announcement says to put it behind a TLS-terminating reverse proxy if other machines need to reach it. There are also -serve and -connect flags if you would rather do this from the CLI.1
CONNECT is the other half. It attaches a remote database and points your session at it in one step, until you DISCONNECT. It isn't Quack-only: the highlights post's example connects to PostgreSQL, and a new remote pushdown optimizer ships the SQL to PostgreSQL and MySQL instead of pulling tables over the wire.2 The previous answer was wrapping your query in a remote.query($$...$$) string, so this is much nicer.
VARIANT shipped in 1.5, but 2.0 integrates it all the way through. DuckDB detects the common structure in your semi-structured data and "shreds" it into columns, Parquet can read and write shredded VARIANT, and there is a family of variant_* functions such as variant_type() and variant_keys().12
Triggers are new too: BEFORE and AFTER, per row or per statement, with transition tables. The announcement pitches them for audit tables and incrementally maintained materialized views, and a few combinations, like BEFORE ... FOR EACH ROW, aren't supported yet.1 Async I/O now covers Parquet, CSV and DuckDB's own format, and the bigger gains are on network storage rather than a local disk.12
The one I didn't see coming is agent mode. The CLI detects Claude Code, Codex and Cursor and switches to compact Markdown tables, says when a result was truncated, stops runaway queries early, reports errors as JSON and estimates the cost of a long query before running it. DuckDB says that on TPC-H this cut the output the model had to read by 59%.1 -agent and -no-agent force it either way. That number is theirs, I didn't test it.
I used uv run --with duckdb==2.0.0 (on PyPI since October 21) and two Python processes on my machine. The server builds a million-row table, times a GROUP BY in-process, then starts serving:
con = duckdb.connect("server_data.duckdb")
con.execute("INSTALL quack")
con.execute("LOAD quack")
con.execute("""
CREATE TABLE big AS
SELECT i AS id, i % 1000 AS bucket, (i * 0.0001)::DOUBLE AS value, 'row-' || i AS label
FROM range(1000000) t(i)
""")
# time the aggregation here (best of 5) before the file is handed to the server
con.execute("CALL quack_serve(token = 'exp_token')")con = duckdb.connect() # separate process, in-memory, no file
con.execute("INSTALL quack")
con.execute("LOAD quack")
con.execute("ATTACH 'quack:localhost' AS qk (TOKEN 'exp_token')")
con.execute("CONNECT qk")
con.execute("SELECT bucket, count(*), avg(value), sum(value) FROM big GROUP BY bucket ORDER BY bucket")I ran the server twice and the client five times in total. In-process, the aggregation took 1.19 to 1.45 ms (best of five). Over Quack, warm, the same query took 1.67 to 2.57 ms, so somewhere between 1.2x and 1.8x the in-process time. ATTACH plus CONNECT cost 15 to 33 ms, once. The results matched the in-process ones.
Two caveats. This is localhost, so it measures protocol overhead, not a real network. And the result is 1,000 small rows: a query that returns a lot of data will pay a lot more to ship it. Still, a warm round trip in the low milliseconds is better than I expected from a protocol this new.
I generated 200,000 lines of nested JSON (about 21 MB: an id, an event type, a nested user object, a timestamp and a tag array) and loaded it twice, once as a VARCHAR column and once as VARIANT.
One gotcha cost me a run. Casting a string with ::VARIANT does not parse it, it just wraps the string, and variant_typeof() happily reports VARCHAR. You need ::JSON::VARIANT, which is also what the highlights post's example does.2
Each table went into its own fresh database file. The VARIANT file was 1.06 MB against 7.35 MB for the VARCHAR one, 14.4% of the size, in both runs. Grouping by event type took 0.65 to 0.68 ms with data.type on the VARIANT column, and 12.8 to 13.1 ms with json_extract_string(data, '$.type') on the text column, roughly 19 to 20x. Both returned the same groups and counts.
Be a bit suspicious of those numbers. My data is generated and very regular, every row has the same keys, which is the best case for shredding. Real JSON with drifting keys will probably do worse. But if you have been storing JSON blobs in DuckDB as text, it's worth trying on your own data.
The old model covers a lot. One process can open a DuckDB file read-write, or many processes can open it read-only, and in that case nobody writes.3 For Parquet on S3, or a .duckdb file that gets rebuilt every night and only read during the day, that's all you need, and it's simpler than running anything.
The 2.0 concurrency docs now say where that stops: writing to DuckDB's native format from multiple processes "is supported through the Quack remote protocol".3 That's the real reason to run a server. It makes sense when several processes need to write the same database, or see each other's changes without reopening a file, or when you want one big machine holding the data and the RAM while smaller services send it queries instead of each pulling the raw files. That last pattern used to mean standing up a warehouse.
A Quack server is still one DuckDB process on one machine. Writes all go through it, and inside it DuckDB uses optimistic concurrency: if two transactions modify the same rows at the same time, one fails with a conflict error and has to retry.3 That is fine for analytics with a handful of writers. It's not fine for a steady stream of small transactional updates landing on the same rows.
Postgres also has a whole operational side that nothing in the 2.0 announcement claims to match, like standby servers that can take over when the primary fails, and continuous archiving with point-in-time recovery.4 So my rough rule: if a few people or services are running analytics over big tables, Quack plus CONNECT is now a good fit. If you need high-volume transactional writes and failover, keep Postgres, and use CONNECT with pushdown to query it from DuckDB. That's what the feature is for.
This is the part I'd actually worry about in production. New databases created by 2.0.0 use storage version 69, and versions before 2.0 cannot read them.15 I created a file with 2.0.0 and opened it with 1.5.6:
IO Error: Trying to read a database file with version number 69,
but we can only read versions between 64 and 68.
The database file was created with a newer version of DuckDB.That's a hard failure, not a warning. The other direction is fine: 2.0.0 opened a file made by 1.5.6. I also had 2.0.0 insert rows and create a new table inside that old file, then checkpoint, and 1.5.6 still read it afterwards. That matches the announcement, which says existing files "remain readable by older versions".1
So the trap is not "one upgraded process breaks the shared file". It's narrower and easier to miss: anything that creates a new database file. A job that rebuilds analytics.duckdb from scratch every night, a script that deletes and recreates its cache, an ATTACH of a path that does not exist yet. Once that code runs on 2.0, every reader still on 1.x gets the error above. The migration guide's advice is to check that every process touching a shared file is on a compatible version.5
None of this is hard to deal with. It's just easy to treat a DuckDB major version like a web framework bump, when it behaves more like a storage engine upgrade: smooth in one direction, a flag day in the other. I'd pin duckdb before CI pulls in 2.0 on its own.
Comments