tutorial · 2026-05-28 · duckbert · 8 min read

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 extensionCALL 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:

bootstrap.sql
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:

list-channels.sql
FROM chat.channels;

Now the feed. Twenty newest from general, newest first. After your INSERT below, re-run — yours lands on top:

read.sql
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).

catalog-mode.sql
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:

adhoc-query.sql
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:

Skipping the catalog entirely

Route 1 in one line — no ATTACH, no catalog state. The connection lives only for this one statement:

stateless-query.sql
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

. Pass 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:

local-remote-join.sql
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.

guestbook.sql
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:

What's coming next

Three threads worth watching from the announcement:

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.