Two Ducks, One Database: A Hands-On Tour of Quack
One DuckDB serves, another attaches over HTTP, and a single statement plans across both. The end of one-writer-at-a-time.
DuckDB does a lot from inside a single process. Crunches gigabytes on your laptop, reads dozens of file formats and remote databases in one statement, runs in your browser. Open it from a second shell — DuckDB pushes back:
That's the in-process tax — one writer at a time. DuckDB UI, notebook, second script: whichever opens first wins, the rest bounce. And across the network? DuckDB attaches to Postgres, MySQL, S3, Iceberg — but not to another DuckDB.
Good news! As of earlier this month, that's the past. The DuckDB team shipped Quack, a client-server protocol built right into the same engine. One line turns a DuckDB process into a tiny server. One more lets another DuckDB attach over HTTP — multiple readers, multiple writers, and the server is still just DuckDB itself: no separate database process to babysit, no new query language to learn, no migration story to manage. DuckDB is multiplayer now.
The interesting bit isn't wire speed — it's that every Quack client is itself a full DuckDB query engine, so a single statement can plan across local and remote tables in a way traditional client-server databases can't.
It's also quick — the announcement has the export benchmarks.
Every interactive block in this post is a real DuckDB-WASM client talking to a real Quack server. That makes you a Quack client already. Open the post in a second tab and you become your own second client — same server, two DuckDBs. Watch state move between them; the guestbook below is the demo.
Over the wire — two clients, one server, slowed right down:
One line and you have a server
On DuckDB 1.5.3+ (released May 20), turning any DuckDB process into a Quack server is a single call:
CALL quack_serve('quack:localhost:9494', token => '<bootstrap-token>');This one has to run server-side, because quack_serve opens a network listener — something DuckDB-WASM (which is what every Run button on this page uses) deliberately can't do from inside a browser tab. That's why this snippet doesn't have a Run button: a DuckDB process running this exact call is already listening at {{quack_url}} for this page, and the runnable blocks below attach to it.
Quack is a core extension — CALL quack_serve(...) is enough to trigger auto-install and auto-load. Before 1.5.3 you'd INSTALL quack FROM core_nightly; LOAD quack; first.
What's quack_serve actually doing? It hands back the listening URI right away and keeps serving requests from the same DuckDB process. By default it binds to localhost; to expose it publicly, change the bind address and put nginx (or any reverse proxy) in front. Quack speaks plain HTTP only — TLS is the proxy's job.
Classic DuckDB — ATTACH and you're up. Even spinning up a DuckLake catalog is the same one-liner.
Attach as a client
Each interactive block runs in a fresh DuckDB-WASM session. We wire up the Quack connection on first run — INSTALL, LOAD, secret, then ATTACH. Quack isn't yet in the DuckDB-WASM core-extension list, so we pull it from core_nightly explicitly. Autoload-on-first-use works in regular DuckDB 1.5.3+ but not in WASM yet, so the two INSTALL/LOAD lines stay. The page already ran this once when the post mounted; hit Run to re-bootstrap a fresh session:
FORCE INSTALL quack FROM core_nightly;
LOAD quack;
CREATE OR REPLACE SECRET (TYPE quack, TOKEN '{{token}}');
DETACH DATABASE IF EXISTS chat;
ATTACH '{{quack_url}}' AS chat;{{token}} is a page-scoped placeholder — when you hit Run the browser swaps it for your minted session token before the SQL leaves the tab. The real token never appears in the source markdown, the rendered editor, or the server's query log. The server URL above is the public hostname of our Quack server; we render it in place for clarity.
Once ATTACH lands, the chat database feels like a local one — CREATE TABLE, INSERT, filter, the lot. The Quack overview calls this "attaching as a full catalog"; the stateless alternative comes further down.
Read what's there
What channels exist on the shared server? Hit Run — the seeded channels come back:
FROM chat.channels;Now the feed. Twenty newest from general, newest first. After your INSERT below, re-run — yours lands on top:
FROM chat.messages WHERE channel_id = 1 ORDER BY ts DESC, id DESC LIMIT 20;Two ways to query — and how to mix in local data
The Quack overview lists two top-level ways to talk to a Quack server:
We took route 2 in the bootstrap ATTACH above. Every query you've run so far has been against that attached catalog. If you'd rather skip the attach, quack_query is the one-shot alternative — same server-side execution, no catalog state to keep around.
And inside the attached catalog there's still a choice for how you write the query.
Ad-hoc SQL inside the catalog
The attached catalog also exposes a query table macro for ad-hoc SQL scoped to that attachment. That means two ways to write the same thing against chat. The difference is who plans the statement.
Direct table reference (what you've been doing). The client's optimizer parses SELECT ... FROM chat.messages, sees the remote relation through the attached catalog, and ships projected + filtered operations off to the server (predicate + projection pushdown).
SELECT channel_id, COUNT(*) AS quacks
FROM chat.messages
GROUP BY channel_id
ORDER BY quacks DESC, channel_id;Ad-hoc SQL via chat.query('...'). The client doesn't even peek at the inner SQL — it's an opaque string passed through. The server's optimizer parses, plans, and runs the whole statement; only the final rows come back:
FROM chat.query(
'SELECT channel_id, COUNT(*) AS quacks
FROM messages
GROUP BY channel_id
ORDER BY quacks DESC, channel_id'
);Same final result either way. The differences:
- Where the planner lives. Direct reference: client.
chat.query: server. - Mix with local tables. Only direct reference can — the client's planner sees both worlds.
chat.queryruns entirely server-side and has no idea your local tables exist. - Wire traffic for aggregates. Direct reference ships projected rows down + aggregates locally;
chat.queryaggregates on the server and returns only the final rows. Big difference when the input is much bigger than the output. - Server-side scope. Inside
chat.query(...), thechatalias is no longer in scope — that's the client'sATTACHalias. The server sees its own table names, so the inner SQL saysFROM messages.
Skipping the catalog entirely
Route 1 in one line — no ATTACH, no catalog state. The connection lives only for this one statement:
SELECT * FROM quack_query('{{quack_url}}', 'FROM chat.channels');No catalog state, no leftover binding. The earlier CREATE OR REPLACE SECRET (TYPE quack, ...) covers this call — the secret is matched by URI host
token => '...' inline if you'd rather skip the secret. Reach for it when you want a single shot against a server you don't plan to talk to again.
Joins across the wire
The client is itself a full DuckDB. So you can CREATE TABLE locally in your browser session, then join it against the remote chat.channels in one SQL statement — DuckDB's planner decides which side runs which piece:
CREATE OR REPLACE TABLE local_channel_meta AS
SELECT * FROM (VALUES
(1, 'open chat for everyone'),
(2, 'announcements only')
) AS t(id, blurb);
SELECT c.id, c.name, lcm.blurb
FROM chat.channels c
LEFT JOIN local_channel_meta lcm ON c.id = lcm.id
ORDER BY c.id;Two storage tiers, one query plan. Every Quack client is itself a full DuckDB engine, so the optimizer mixes local and remote scans freely inside a single statement. With Postgres or MySQL, pulling the same join takes a foreign data wrapper, an external table, or a federation engine like Trino sitting in front of both. Quack ships it as the default.
Your turn — write us a row
You've been reading from the shared chat.messages table — now let's use Quack to write to it. Edit the body below and hit Run. Your row joins the same feed every other reader of this post is pointing at, and it sticks around through page reloads, our coffee breaks, and the next deploy.
Tell us what you'd build with Quack. Tell us where it falls over. Tell us hi from <your city> and we'll wave back in the logs.
INSERT INTO chat.messages (id, channel_id, body, ts)
VALUES (uuid(), 1, 'hi from <your city>', now());What's on the wall right now
Did your row land? Did anyone else's? The widget below polls chat.messages every 30 seconds and shows the last twenty quacks. Watch your INSERT show up next time the timer ticks.
What you can do today
Once one DuckDB can talk to another, the patterns get cheaper:
- Telemetry ingest plus a live dashboard, in one DuckDB. N writers stream events in, M readers query for the dashboard — the message wall above, scaled up.
- Browser frontend, server-side DuckDB, no API layer. DuckDB-WASM in the tab attaches to a Quack server directly over HTTPS — exactly what this post runs on.
- Notebook on your laptop, writer on a server. Analysts
SELECTagainst the shared DB while another process bulk-loads it — the local+remote join pattern above, with a Jupyter kernel on the other side. No more "kill the writer before you can look". - Edge nodes to a central rollup. DuckDB on edge boxes (small footprint), periodic flush to a central Quack server that runs scheduled rollups against the union.
- Replace hand-rolled RPC shims. One official protocol, no bespoke glue.
What's coming next
Three threads worth watching from the announcement:
- Replication. A replication protocol layered on Quack so changes propagate from one DuckDB to others. Read replicas are the obvious first shape; the design isn't pinned yet.
- DuckLake-as-catalog over Quack. A remote DuckDB serving as the live, shared catalog for a DuckLake, attached via the same pattern.
- Extension protocol messages. DuckDB extensions can add new message types on the same Quack connection — a vector search extension could expose RPCs that don't map cleanly to SQL.
Already running DuckDB embedded? Quack keeps the engine, the SQL, and the file format — and adds multi-writer.
Two ducks, one database. From Conflicting lock is held to a flock of DuckDBs chatting over HTTP — one call apart. The pond is open.