This guide takes you from a freshly cloned repo to running your first remote query in under ten minutes.
You will:
- Build the Rust backend.
- Build the DuckDB extension.
- Start the gateway + worker.
- Run a query from Python and from the CLI.
- Rust — latest stable. Install via rustup.
- DuckDB build dependencies —
cmake,make, a C++17 compiler, andvcpkg. On macOS you also needbison(brew install bison). - Python 3.9+ (only if you plan to use the Python client).
- Postgres is optional and only needed if you want differential storage (skip for now).
git clone https://github.com/openduck/openduck
cd openduck
cargo build --workspace --releaseThis produces a single binary at target/release/openduck (the unified CLI: serve / gateway / worker / query / cancel / status / snapshot / gc).
The extension is not in DuckDB's extension repository, so you build it from source.
cd extensions/openduck
make # honours vcpkg + bison; takes a few minutes the first time
cd -The build produces:
extensions/openduck/build/release/extension/openduck/openduck.duckdb_extension
Export this for the Python wrapper to find it automatically:
export OPENDUCK_EXTENSION_PATH=$(pwd)/extensions/openduck/build/release/extension/openduck/openduck.duckdb_extensionHeads up: the extension is unsigned, so every DuckDB connection needs
allow_unsigned_extensions = true. The Python wrapper sets this for you. For rawduckdbor the CLI, you have to opt in.
export OPENDUCK_TOKEN=demo-token
./target/release/openduck -d mydb -p $OPENDUCK_TOKENYou should see:
INFO openduck: starting openduck storage=Direct db="mydb" duckdb=v1.x.x worker=127.0.0.1:9898 gateway=0.0.0.0:7878 hybrid=false max_in_flight=64
That single binary started a worker (on 127.0.0.1:9898) and a gateway (on 0.0.0.0:7878) in the same process, with a local mydb DuckDB file as storage.
In another terminal, sanity-check that it's reachable:
./target/release/openduck status \
--endpoint http://127.0.0.1:7878 \
--token $OPENDUCK_TOKEN
# → OK http://127.0.0.1:7878 connect=12ms query=4ms./target/release/openduck query \
"CREATE TABLE users (id INTEGER, name VARCHAR); \
INSERT INTO users VALUES (1, 'Ada'), (2, 'Grace'); \
SELECT * FROM users;" \
--endpoint http://127.0.0.1:7878 \
--token $OPENDUCK_TOKEN \
--format table┌────┬───────┐
│ id │ name │
├────┼───────┤
│ 1 │ Ada │
│ 2 │ Grace │
└────┴───────┘
Install the wrapper:
pip install -e clients/python
export OPENDUCK_TOKEN=demo-tokenThen:
import openduck
con = openduck.connect("mydb") # uses OPENDUCK_TOKEN, defaults endpoint
con.sql("SELECT * FROM users").show()The wrapper:
- Opens a local DuckDB connection with
allow_unsigned_extensions = true. - Loads the extension (auto-detects
OPENDUCK_EXTENSION_PATHor finds it underextensions/openduck/build/). ATTACH 'openduck:mydb?...'as thecloudalias and sets it as the default schema.
Tables resolve transparently — SELECT * FROM users works even though users lives on the remote worker.
import duckdb
ext = "extensions/openduck/build/release/extension/openduck/openduck.duckdb_extension"
con = duckdb.connect(config={"allow_unsigned_extensions": "true"})
con.execute(f"LOAD '{ext}';")
con.execute("ATTACH 'openduck:mydb?endpoint=http://localhost:7878&token=demo-token' AS cloud;")
con.sql("SELECT * FROM cloud.users").show()duckdb -unsigned -c "
LOAD 'extensions/openduck/build/release/extension/openduck/openduck.duckdb_extension';
ATTACH 'openduck:mydb?endpoint=http://localhost:7878&token=demo-token' AS cloud;
SELECT * FROM cloud.users;
"Restart the service with --hybrid:
OPENDUCK_TOKEN=demo-token ./target/release/openduck -d mydb -p demo-token --hybridThen in Python:
import openduck
with openduck.connect("mydb") as con:
con.raw.execute("CREATE TABLE local.products(id INT, label VARCHAR);")
con.raw.execute("INSERT INTO local.products VALUES (1, 'Widget'), (2, 'Gadget');")
con.raw.sql("""
SELECT p.label, u.name
FROM local.products p
JOIN cloud.users u ON p.id = u.id
""").show()The local table lives in the client's in-memory DuckDB; cloud.users lives on the worker. The gateway pulls just the users rows back as Arrow IPC and the join runs locally.
For the explicit openduck_run form and how the planner splits the work, see the Hybrid execution guide.
┌──────────────────────────┐ ┌─────────────────────────┐
│ Your Python process │ │ openduck (one process) │
│ │ │ │
│ duckdb (in-memory) │ │ ┌───────────────────┐ │
│ ├ openduck extension │ gRPC │ │ Gateway │ │
│ ├ ATTACH cloud │ ──────▶ │ │ :7878 │ │
│ └ SELECT FROM cloud... │ │ └────────┬──────────┘ │
└──────────────────────────┘ │ │ │
│ ┌────────▼──────────┐ │
│ │ Worker │ │
│ │ :9898 │ │
│ │ embedded duckdb │ │
│ │ mydb (file) │ │
│ └───────────────────┘ │
└─────────────────────────┘
In the simplest setup the worker and gateway live in the same openduck process. To scale out, run them separately — see Deployment.
- Python client — full API of the
openduckPython package. - DuckDB extension —
ATTACHURI format, secrets,openduck_remote()andopenduck_query()table functions. - Differential storage — switch from a plain
.duckdbfile to layered, snapshot-isolated storage. - Hybrid execution — split single queries across local and remote.
- Configuration — every flag, env var, and TOML key in one place.