# OpenDuck makes DuckDB attach a remote database and split a query across two machines

> OpenDuck is an MIT licensed DuckDB extension plus a Rust gateway and worker that let a local DuckDB process attach a remote database with ATTACH, resolve remote tables in the local catalog, and split a single query between the laptop and a remote worker over an open gRPC and Arrow IPC protocol. It suits a data team that wants a cloud database feel without a cloud vendor, and the extension is not yet published, so it must be built from source.

**CITGuru/openduck** — Distributed DuckDB - dual execution and differential storage

- Repository: https://github.com/CITGuru/openduck
- Stars: 576 · Forks: 28
- Language: C++
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/citguru-openduck

## One ATTACH statement turns a local DuckDB into a client

OpenDuck makes DuckDB work like a cloud database without giving up its embedded database feel. You attach a remote database in one line, tables resolve transparently, a single query can split its work across your laptop and a remote worker, and storage underneath is layered, snapshot-based and concurrency-safe.

The client side looks like ordinary DuckDB, and that is the point.

```python
import duckdb

con = duckdb.connect(config={"allow_unsigned_extensions": "true"})
con.execute("LOAD '/path/to/openduck.duckdb_extension';")
con.execute("ATTACH 'openduck:mydb?endpoint=http://localhost:7878&token=xxx' AS cloud;")

con.sql("SELECT * FROM cloud.users").show()                    # remote, transparent
con.sql("SELECT * FROM local.t JOIN cloud.t2 ON ...").show()   # hybrid, one query
```

The first query names a schema-qualified remote table and gets rows back. The second joins a local table against a remote one in a single statement, with no application code doing the join.

The architecture is a DuckDB extension plus a small Rust gateway and worker speaking an open gRPC and Arrow IPC protocol, so you self-host the whole thing or plug your own backend in. The design path is stated openly: OpenDuck follows the approach MotherDuck pioneered with differential storage, dual execution and the md: attach scheme, and reimplements those ideas as an open protocol and an open backend you can run yourself.

That is an unusually candid opening for a project in this space, and it tells you the design brief before you read a line of code.

## Dual execution splits a plan and labels every operator

The hybrid execution model is the most interesting part of the design, and it is described in concrete terms.

A single query can run partly on your machine and partly on a remote worker. The gateway splits the plan, labels each operator LOCAL or REMOTE, and inserts bridge operators at the boundaries. Only intermediate results cross the wire.

The documentation gives the shape of the annotated plan.

```
[LOCAL]  HashJoin(l.id = r.id)
  [LOCAL]  Scan(products)          ← your laptop
  [LOCAL]  Bridge(R→L)
    [REMOTE] Scan(sales)           ← remote worker
```

The point of the labelling is that it is visible. You can see which operator ran where, and the bridge operators mark exactly where data moves. That matters for two reasons. The first is predictability: a plan that silently ships half your data to a remote machine is a surprise, and here the boundary is explicit. The second is cost, because only intermediate results cross the wire rather than whole tables.

A hash join is the example used, and it is the right one. A join is where a local scan meets a remote scan, so the interesting decision, how much data to ship versus how much to compute remotely, is exactly what a bridge operator has to make.

The gateway in the architecture diagram carries the responsibilities: token auth, a worker registry, affinity routing, plan splitting and backpressure. Affinity routing means the gateway can send a query to a worker that already holds the relevant data, and backpressure is what stops a slow remote worker from turning into a queue in your laptop.

## Differential storage is sealed layers over object storage

The storage model is where the resemblance to a cloud warehouse comes from, and it is a different design from DuckDB's own file format.

Append-only layers with PostgreSQL metadata. DuckDB sees a normal file, while OpenDuck persists data as immutable sealed layers addressable from object storage. Snapshots give consistent reads. One serialized write path, many concurrent readers.

Each phrase carries weight. Append-only means a write never modifies what is already there, it adds a layer, so a snapshot is just a pointer to a set of layers and a read is a merge of those layers. That is how object storage backends work, and it is why the write path can be serialised while readers proceed in parallel: readers are not blocked by the writer because the writer is not changing their data.

The metadata lives in Postgres, and the workspace dependencies show that choice concretely, with sqlx against Postgres and uuid and chrono support. The data lives in an object store, with the object_store crate configured for AWS.

There is also a diff-fuse crate in the workspace and a fuse_duckdb.sh example script, which points at a FUSE layer exposing the remote database as a local file. That is a coherent addition rather than a stray: a FUSE mount lets tools that expect a file path, including DuckDB itself, work against the remote store without the extension.

The trade is that this is a different durability model from a local DuckDB file. Layers are immutable, so a compaction and a garbage collection policy becomes your problem, and the documentation does not describe one.

## The extension plugs into DuckDB's own catalog interfaces

How the remote tables become visible to the local engine is the part that determines whether queries join naturally or not.

The extension implements DuckDB's StorageExtension and Catalog interfaces. Remote tables are first-class catalog entries, they participate in JOINs, CTEs and the optimizer like local tables.

This is the mechanism behind the transparency claim. If remote tables were a separate thing the engine had to special-case, a query joining a local and a remote table would need its own path, and the optimizer would treat the remote side as opaque. Implementing the catalog interfaces means the optimizer sees a table with a schema and can plan against it the same way it plans a local table, which is what makes a mixed local and remote join a single statement.

The protocol is deliberately minimal, and it is defined in execution.proto under proto/openduck/v1. The data plane is two RPCs: one to execute a query and stream Arrow IPC batches back, and another to cancel a running execution. Two additional RPCs handle worker lifecycle, registration and heartbeat, so the gateway can route queries by database affinity and compute context.

Two RPCs for data, two for lifecycle. That is a small surface, and it is the reason the documentation can say any service that speaks gRPC and returns Arrow can serve as an OpenDuck-compatible backend. You can run the included Rust gateway, replace it with your own implementation, or plug in an entirely different execution engine, because the client and the extension do not care what is on the other side.

## Building it: a Rust workspace and an extension you compile yourself

The quick start is four steps, and the third one is a constraint worth knowing about before you start.

The backend is a Cargo workspace.

```bash
cargo build --workspace
```

The extension is not yet published to DuckDB's extension repository, so you need to build it from source, and the extension README lists the full prerequisites including vcpkg and bison on macOS.

```bash
cd extensions/openduck && make
```

That produces the loadable binary at extensions/openduck/build/release/extension/openduck/openduck.duckdb_extension.

Then the server, with a token in the environment.

```bash
export OPENDUCK_TOKEN=your-token
cargo run -p openduck -- -d mydb --token your-token
```

The workspace layout tells you the internal structure. The diff- crates handle the storage side: diff-core, diff-metadata, diff-layer-fs, diff-blob and diff-bridge. The exec- crates handle execution: exec-proto for the protocol, exec-gateway and exec-worker for the two processes. There is an openduck-cli and an openduck-metrics crate, and the extension itself lives in extensions/.

The metrics crate and the opentelemetry dependencies in the workspace, with the otlp exporter over gRPC and the tonic version, tell you the operational side is not an afterthought either.

## Unsigned extensions mean every client needs two settings

Because the extension is unsigned, every DuckDB connection needs allow_unsigned_extensions enabled and an explicit LOAD with the full path to the built binary. That is a real cost of the current state of the project, and it touches every client you write.

For the Python SDK the path is inline.

```python
con = duckdb.connect(config={"allow_unsigned_extensions": "true"})
con.execute("LOAD 'extensions/openduck/build/release/extension/openduck/openduck.duckdb_extension';")
con.execute("ATTACH 'openduck:mydb?endpoint=http://localhost:7878&token=your-token' AS cloud;")

con.sql("SELECT * FROM cloud.users LIMIT 10").show()
```

You can avoid hard-coding the path by setting OPENDUCK_EXTENSION_PATH in the environment, and the Python wrapper package takes that further, finding the extension automatically from the build tree or from OPENDUCK_EXTENSION_PATH.

```bash
pip install -e clients/python
export OPENDUCK_TOKEN=your-token
```

```python
import openduck

con = openduck.connect("mydb")
con.sql("SELECT 1 AS x").show()
```

The CLI shows the same requirement expressed as a flag.

```bash
duckdb -unsigned -c "
  LOAD 'extensions/openduck/build/release/extension/openduck/openduck.duckdb_extension';
  ATTACH 'openduck:mydb?token=your-token' AS cloud;
  SELECT * FROM cloud.users LIMIT 10;
"
```

And the Rust example sets the same flag with SET allow_unsigned_extensions = true before the LOAD.

So a client in any of these languages needs the same two things: permission to load an unsigned extension, and the path to the binary. That is a configuration burden you would carry until the extension is published and signed.

## Where OpenDuck is the wrong shape for the job

There are honest reasons to look elsewhere, and they are about consistency and about who operates the thing.

The first is the consistency model. DuckDB is a single-process analytical database with a serialisable local file, and the reason people reach for it is that a query against it is a query against one consistent snapshot. OpenDuck keeps that property for the remote side, since snapshots give consistent reads, but a query that spans local and remote data is by construction reading two systems at two moments. The documentation does not describe a distributed snapshot across the boundary, so a query that joins a local table against a remote one has an inherent skew that a single-machine DuckDB never has. For reporting and analytics that is usually fine. For anything that has to reconcile, it is a decision you are making rather than a property you are getting.

The second is the operational surface. A running OpenDuck deployment is a Rust gateway, at least one worker, Postgres for metadata, an object store for the layers, and a DuckDB extension that has to be distributed to every client that connects. The extension being unsigned is what makes that last part awkward today, and the documentation is honest that it is not yet published.

The honest alternative is to not build the distributed layer. If the requirement is a shared analytical database that several people query, a single DuckDB file on a shared volume, or a server that speaks the DuckDB wire protocol, gives you the query engine without the plan splitting, the layer storage and the gateway. OpenDuck is for the case where the data genuinely has to live in object storage and the compute has to be spread across machines, and the protocol being open is what keeps that from being a commitment to one vendor.

## Conclusion

OpenDuck fits a data team that wants to keep DuckDB as its query engine while moving storage and compute to a server it controls, and that is willing to run a Rust gateway, a worker and Postgres metadata before it gets a first query. It does not fit a team that needs a managed service today, since the extension is not published to DuckDB's extension repository and has to be built from source, and it does not fit a workload that needs strong consistency across the local and remote sides, because the storage design is append-only sealed layers with snapshot reads. Before adopting it, build the extension and the workspace, run the gateway and worker locally, and confirm that the differential storage model, with immutable layers in object storage and metadata in Postgres, matches the durability and backup guarantees your data needs.

## FAQ

### How do I attach a remote database with OpenDuck?

Load the built extension and attach with the openduck scheme, for example ATTACH 'openduck:mydb?endpoint=http://localhost:7878&token=xxx' AS cloud. Because the extension is unsigned, every connection also needs allow_unsigned_extensions enabled and an explicit LOAD with the full path to the built binary.

### How does OpenDuck split a query between local and remote execution?

The gateway splits the plan, labels each operator LOCAL or REMOTE and inserts bridge operators at the boundaries, so only intermediate results cross the wire. The gateway also handles token auth, the worker registry, affinity routing and backpressure.

### Can I use a different backend instead of the bundled Rust gateway?

Yes. The protocol is intentionally minimal and defined in execution.proto, with two data plane RPCs for executing a query and streaming Arrow IPC batches and for cancelling an execution, plus two lifecycle RPCs. Any service that speaks gRPC and returns Arrow can serve as an OpenDuck-compatible backend.

### Where does OpenDuck store data and metadata?

Differential storage keeps data as immutable sealed layers in object storage, with PostgreSQL holding the metadata. The workspace dependencies show sqlx against Postgres and the object_store crate configured for AWS, and DuckDB still sees a normal file.

## Sources

- [CITGuru/openduck on GitHub](https://github.com/CITGuru/openduck)
- [Issues](https://github.com/CITGuru/openduck/issues)
- [License: MIT](https://github.com/CITGuru/openduck/blob/main/LICENSE)
- [README](https://github.com/CITGuru/openduck/blob/main/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/citguru-openduck
