DuckDB
forze[duckdb] implements the analytics query contract with an embedded
DuckDB engine. Where ClickHouse and BigQuery are
standing warehouses, DuckDB is compute without a server: point it at Parquet,
CSV, Iceberg or Delta files on S3/GCS (or local disk) and it runs the scan
in-process. It suits heavy analytics that don't need ultra-low-latency
serving — and, being Docker-free, it doubles as an analytics backend in tests.
It is query-only (no ingest, no table management): named, parameterized queries returning typed rows, exactly like the other analytics adapters. With a per-tenant routed client it also serves trusted dynamic reads over an Iceberg lake.
Install¶
uv add 'forze[duckdb]'
No server to run — DuckDB is a library. Reading object storage pulls the
httpfs extension (and iceberg / delta for those formats) on first use.
The extra installs DuckDB 1.5 or 2 (duckdb>=1.5.6,<3). Both clients run on
both majors with the same code. The repository's lock tests on 2.0.0, and CI
runs the DuckDB suites on 1.5.6 as well. Where the per-tenant lake client
behaves differently on the two, the section on it says so.
The client¶
from forze_duckdb import DuckDbClient
duck = DuckDbClient()
The engine is synchronous and in-process; the client runs each query on a dedicated bounded executor (DuckDB releases the GIL, so the event loop stays responsive) with its own cursor, and honors per-query timeouts via interrupt.
The lake source¶
Sources are declared as typed descriptors that compile to scan expressions and auto-load the extensions they need — no hand-written SQL strings:
from forze_duckdb import ParquetSource, IcebergSource, DeltaSource
events = ParquetSource("s3://bucket/events/*.parquet") # also CsvSource, JsonSource
Object-storage credentials render to DuckDB secrets, resolved at startup from the
wired secrets backend via a secret_ref (or supplied inline for
local runs):
from forze.application.contracts.secrets import SecretRef
from forze_duckdb import S3Credentials
creds = S3Credentials(name="lake", secret_ref=SecretRef(path="lake/s3"))
Settings¶
DuckDbSettings carries the database path and the resource limits — memory_limit in
particular, since DuckDB otherwise sizes itself from the host's memory rather than the
cgroup's, which is how a query gets the container OOM-killed. Extensions, object stores and
named sources stay wiring arguments. See
connection settings.
Wire it¶
Each analytics route maps query_keys to DuckDB SQL (referencing a registered
source view or an inline read_parquet(...)), keyed by AnalyticsSpec.name. The
lifecycle step opens the connection, loads extensions, registers credentials and
source views:
from forze.application.execution import DepsRegistry, LifecyclePlan
from forze_duckdb import (
DuckDbAnalyticsConfig,
DuckDbClient,
DuckDbDepsModule,
DuckDbQueryConfig,
ParquetSource,
S3Credentials,
duckdb_lifecycle_step,
)
from forze.application.contracts.secrets import SecretRef
duck = DuckDbClient()
events = DuckDbAnalyticsConfig(
queries={"by_day": DuckDbQueryConfig(
sql="SELECT day, sum(total) AS total FROM events WHERE day >= $since GROUP BY day",
)},
)
deps = DepsRegistry.from_modules(DuckDbDepsModule(client=duck, analytics={"events": events}))
lifecycle = LifecyclePlan.from_steps(
duckdb_lifecycle_step(
object_stores=(S3Credentials(name="lake", secret_ref=SecretRef(path="lake/s3")),),
sources={"events": ParquetSource("s3://bucket/events/*.parquet")},
),
)
For a runnable, Docker-free walkthrough, see the analytics recipe.
Per-tenant lake reads (dedicated)¶
One DuckDbClient is one engine for every tenant: whatever credentials it holds,
every statement can use. RoutedDuckDbClient instead builds an unnamed
:memory: instance per tenant, attached to that tenant's own warehouse in an
Iceberg REST catalog (Lakekeeper) with the tenant's own OAuth2 principal. The
instance holds that principal's client secret for its whole life, and the S3
credentials the catalog vends for a statement until the statement ends. Neither
reaches another tenant's data. That is the dedicated tier, and
it is what lets a dynamic-read route run
compiled statements here:
from datetime import timedelta
from forze.application.contracts.secrets import SecretRef
from forze.application.execution import DepsRegistry, LifecyclePlan
from forze.application.execution.lifecycle.builtin import routed_client_lifecycle_step
from forze_duckdb import (
DuckDbDepsModule,
DuckDbDynamicReadConfig,
LakeTenantCatalog,
RoutedDuckDbClient,
)
lake = RoutedDuckDbClient(
secrets=secrets, # SecretsPort; holds LakeCatalogCredentials
secret_ref_for_tenant=lambda t: SecretRef(path=f"tenants/{t}/lake"),
tenant_provider=current_tenant,
catalog_for_tenant=lambda t: LakeTenantCatalog(
uri="https://lakekeeper:8181/catalog",
warehouse=f"wh-{t}",
oauth2_server_uri="https://idp/realms/lake/protocol/openid-connect/token",
oauth2_scope="lakekeeper",
storage_prefix=f"s3://lake/{t}/",
),
extension_directory="/opt/duckdb/extensions", # httpfs, iceberg, avro: in the image
ca_cert_file="/etc/ssl/certs/ca-certificates.crt", # HTTPS on DuckDB 2 reads it
)
widgets = DuckDbDynamicReadConfig(
provenance="trusted", # the only tier admitted
namespace="gold", # USE lake.gold before every statement
statement_timeout=timedelta(seconds=5),
)
deps = DepsRegistry.from_modules(DuckDbDepsModule(client=lake, dynamic_reads={"widgets": widgets}))
lifecycle = LifecyclePlan.from_steps(routed_client_lifecycle_step("duckdb_lake", client=lake))
The secret holds LakeCatalogCredentials: client_id and client_secret, never
a static token, because a token is never refreshed. An empty client_id is
refused on first use, since the iceberg build for DuckDB 1.5 never refreshes a
token issued for one.
Each instance is built by one fixed sequence and serves nothing until it has read the result back:
connect(":memory:", persistent secrets off, extension autoload/autoinstall off,
temp_directory '', extension_directory)
LOAD httpfs; LOAD iceberg -- never INSTALL; iceberg also loads avro
SET GLOBAL enable_global_s3_configuration=false; s3 keys cleared
SET GLOBAL ca_cert_file -- if configured
SET GLOBAL allowed_directories=[storage_prefix, catalog uri/]
SET GLOBAL allowed_paths=[token endpoint, ca_cert_file]
SET GLOBAL enable_external_access=false
CREATE SECRET (TYPE iceberg, client credentials, scope)
ATTACH warehouse AS lake (vended_credentials, READ_ONLY)
SET GLOBAL http_timeout; HTTP metadata and external file caches off
SET GLOBAL lock_configuration=true
attest -- settings, secrets, databases, refusals
The allowlist is the one bound inside the engine on what can be opened, local
or remote. Anything outside it is refused before any request: another
tenant's prefix, a local path, any other URL. It names the catalog and the
IdP because DuckDB 2 checks the iceberg extension's own requests against
it. Analytics SQL on the instance can therefore also send GETs into the
catalog's tree, which every tenant shares, and to the token endpoint. They
carry no token, and the catalog and the IdP refuse them. The allowlist
matches a path by its spelling: it resolves no .., and it does not re-check
where a redirect leads. No filesystem is disabled: DuckDB 2 reaches neither the
catalog with HTTPFileSystem off nor its CA bundle with LocalFileSystem off.
For HTTPS to the catalog, the IdP or the store, set ca_cert_file to a PEM
bundle: DuckDB 2 reads it through the allowlist, and without it the session does
not build. Any statement on the instance can read that file. The same sequence
runs on DuckDB 1.5.
What keeps credentials in is not the allowlist but their being unreadable:
duckdb_secrets() stays redacted, the s3_* options are cleared, no secret
persists, and the lock keeps it so. The attestation reads each of these back,
and checks by effect that a local path, an unlisted URL, the token endpoint
with a path or query added, a sibling of the prefix or the catalog tree, a file
beside the CA bundle, and a write to lake are all refused.
A session that does not attest raises duckdb_session_attestation_failed, and one
whose sequence fails (an extension missing from the image, a principal the catalog
refuses) raises duckdb_session_setup_failed. Both are configuration errors, and
the instance is closed. A setup error carries the engine's message with the
tenant's client secret and DuckDB's quote of the statement removed, and does not
chain the engine's error. Each statement then runs on a fresh cursor:
USE lake.<namespace>, DuckDB's own parser checks that the text is exactly one
SELECT, the classified statement runs unmodified, and a result with two columns
of the same name is refused. A dynamic read may read only tables it names
(dynamic_read_statement_invalid otherwise):
- no table function but
range,generate_seriesandunnest. The others take a path, a URL, a table name or SQL text (read_parquet,iceberg_scan,query,query_table), andPRAGMAis refused on 1.5 as on 2; - no path or URL as a table name, so nothing a statement writes reaches the
allowlist's spelling match. Parsing binds nothing, so any name holding
/,:or.counts as one: a table whose own name has a dot cannot be read dynamically; - no table named through a catalog the instance has not attached, or nested
deeper than
catalog.schema.table: DuckDB 2 would read it as a nested namespace and resolve only its last level, a table the statement did not name.
After every read, dynamic or analytics, the
per-table S3 secrets the catalog vended for it are dropped. On DuckDB 1.5 a
kept secret outranks the next statement's fresh one, so once it expired every
later read of that table would fail. DuckDB 2 discards it when the statement
ends, and the drop finds nothing. If a drop fails, the read's own rows or error still come
back, and the instance is retired: it refuses further statements
(duckdb_instance_retired), and the next call builds a fresh one.
What the route promises, and who keeps each promise:
| Property | Kept by |
|---|---|
| Read-only | the catalog's select-only grant and the store; the READ_ONLY attach and parser classification are belts |
| One statement | DuckDB's parser, before anything runs |
| Timeout | the client interrupts the cursor, also when the caller is cancelled. A CPU-bound statement stops at once. A catalog or store request that hangs is not cut short: the caller still gets dynamic_read_timeout on time, but the worker holds its slot until the request ends. On DuckDB 2 that is http_timeout × (1 + http_retries, default 3); on 1.5 about 120 s, whatever http_timeout says |
| Row cap | the shell fetches cap + 1 rows from a streaming result and refuses the overflow |
| Container | the tenant's instance, principal and vended credentials (dedicated) |
| Untrusted statements | refused at wiring (duckdb_dynamic_read_untrusted_refused); a provenance other than "trusted" or "untrusted" is duckdb_dynamic_read_provenance_invalid |
Wiring refuses a dynamic route on a plain DuckDbClient (its origin floor is
namespace), and an http_timeout above the smallest route timeout. Named
analytics routes work on the routed client too and derive dedicated; their
SQL names tables as lake.<namespace>.<table>. A nested namespace is written
dotted, namespace="gold.sub". DuckDB lists it as one schema, so SQL names its
tables bare or as lake."gold.sub".<table>.
Operating it:
- Size
max_concurrent_queries_totalfor hung requests. It bounds statements across all tenants (default 8), and peak memory is about that many timesengine.memory_limit. A tenant whose catalog or store stops answering holds a slot per stuck request:http_timeout× (1 +http_retries) on DuckDB 2, about 120 s on 1.5. Waiting for a slot counts against a statement's timeout. - Call
evict_tenantafter a grant is restored or a namespace is created. An instance that once found a namespace missing keeps it missing for its life. Rotate credentials or remap a tenant's catalog the same way, or setfingerprint_ttl. An evicted instance closes in the background, andclose()waits for those teardowns. - A revocation lands at the next statement, or after the authorizer's check cache expires: OpenFGA caches checks for 10 s when its cache is on.
- The deployment owns the outer controls. The worker must hold no ambient
cloud credentials, write nowhere it should not, and egress only to the
catalog, the IdP and the store. An
s3://path inside the tenant's prefix with no vended secret behind it goes, unsigned, to the public AWS endpoint. The engine follows a redirect from any of the three without re-checking it, and the store, not the allowlist, refuses a..key outside the vended credential's table. - Keep
sts-token-validity-secondswell above the longest statement. The engine does not re-vend a credential that expires mid-statement: the statement fails with an HTTP 400 from the store. A statement that starts after expiry loads the table again and gets a fresh credential.
A DuckLake per tenant¶
A tenant's lake can also be a DuckLake: no REST
catalog and no IdP, just a metadata schema in a Postgres database, read with the
tenant's own role, and Parquet files under a data path. Return a
DuckLakeTenantCatalog from catalog_for_tenant and keep everything else
above, routes and wiring included:
from forze_duckdb import DuckLakeTenantCatalog, RoutedDuckDbClient
lake = RoutedDuckDbClient(
secrets=secrets, # SecretsPort; holds DuckLakeCatalogCredentials
secret_ref_for_tenant=lambda t: SecretRef(path=f"tenants/{t}/ducklake"),
tenant_provider=current_tenant,
catalog_for_tenant=lambda t: DuckLakeTenantCatalog(
host="postgres",
port=5432,
database="app",
metadata_schema=f"t{t.hex}_lake",
data_path=f"/var/lib/lake/{t}/", # or s3://lake/<tenant>/
),
extension_directory="/opt/duckdb/extensions", # ducklake, postgres (+ httpfs): in the image
)
The secret holds DuckLakeCatalogCredentials: user and password for the
tenant's metadata role and, for an s3:// data path, store, an S3 key in the
S3SecretPayload shape. An empty role or password is refused
(duckdb_lake_role_credentials_empty), since libpq would fall back to the OS
user or the process's own credentials. A store key on a local path, or none on
an s3:// one, is refused (duckdb_lake_store_credentials_mismatch). One client can serve
Iceberg tenants and DuckLake tenants side by side: each tenant's catalog picks
the shape of its secret.
Each instance is built by this sequence, on DuckDB 1.5 and 2:
connect(":memory:", persistent secrets off, extension autoload/autoinstall off,
temp_directory '', extension_directory)
LOAD ducklake; LOAD postgres [; LOAD httpfs] -- never INSTALL; httpfs for s3://
[SET GLOBAL enable_global_s3_configuration=false; s3 keys cleared]
SET GLOBAL ca_cert_file -- if configured, s3:// only
SET GLOBAL allowed_directories=[data_path]
SET GLOBAL allowed_paths=[ca_cert_file] -- empty on a local data path
CREATE SECRET (TYPE postgres, host, port, database, user, password)
[CREATE SECRET (TYPE s3, the store key, SCOPE data_path)]
ATTACH 'ducklake:postgres:' AS lake (DATA_PATH, METADATA_SCHEMA, READ_ONLY)
SET GLOBAL enable_external_access=false
[SET GLOBAL http_timeout; HTTP metadata cache off]
SET GLOBAL enable_external_file_cache=false
SET GLOBAL lock_configuration=true
attest -- settings, secrets, databases,
-- refusals, the role's grants
It differs from the Iceberg sequence where DuckLake needs it to:
- It attaches before external access goes off. The
postgresextension refuses to attach once it is off. A held instance still sees what the build commits later, new tables included, with no refresh. - The password lives in an unnamed
postgressecret, never in the attach string.duckdb_databases()hands the attach string to any statement, and the attestation checks that it is the barepostgres:. A statement sees the secret only redacted. - The role's grants bound the metadata database. A statement can query it
as the role through
postgres_query('__ducklake_metadata_lake', …)and read__ducklake_metadata_lake.<schema>.*, both in a read-only transaction. The attestation refuses the session when the role:- can write any table, view or sequence in its schema, or create in it;
- can read another DuckLake's metadata, which can hold that lake's inlined rows;
- can read anything else outside its schema that
PUBLICcannot, by a column grant too; - finds a view in its schema that reads outside it, directly, through another view or through a function, since a view runs with its owner's rights;
- is a superuser.
Grant the role USAGE on its schema and SELECT on its tables, and nothing
else. A SECURITY DEFINER function it may execute reaches past all of this,
so keep none callable by it.
- ATTACH adds four paths named postgres:… in the working directory to
allowed_paths. A later SET does not remove them, so a statement can read
and write those four files. The attestation expects exactly them.
A dynamic read refuses all of this already: postgres_query, postgres_scan
and the ducklake_* functions are table functions outside its list, and the
metadata database is a catalog the instance does not list. Analytics SQL, which
the application writes, can call them.
Operating it, beyond the Iceberg list:
- A local data path must be its own real path, or the build is refused: the allowlist matches the spelling, and the filesystem follows a symlink. Keep any symlink out from under it too, since the attestation does not walk the tree.
- On
s3://, the store key is what confines reads to the prefix. The engine refuses a..in a local path, and the attestation checks that, but it sendss3://<prefix>/../<other>/…to the store as it is. Scope each tenant's key to its own prefix. - The engine admits writes under the data path. The allowlist is not
read-only:
COPY … TOa file there succeeds, and on DuckDB 2ducklake_delete_orphaned_filesdeletes files there althoughlakeis attachedREAD_ONLY. Only the outside closes it: a read-only key for ans3://path, a read-only mount for a local one. A builder writing the same directory from the same process cannot have the mount. - On DuckDB 1.5,
postgres_scanandpostgres_attachtake a connection string and connect to any Postgres, whateverenable_external_accesssays. libpq then also reads the process'sPGPASSWORD,PGPASSFILE,PGSERVICEFILEand~/.pgpass. DuckDB 2's extension has neither function. Run the worker with none of those variables set and no readable~/.pgpass, and let its egress policy allow only the metadata database and the store. - Run the worker in a read-only working directory, so the four
postgres:…paths cannot be written. - Call
evict_tenantafter rotating the role's password or the store key, or setfingerprint_ttl, as for an Iceberg principal.
What it provides¶
| Contract | Keyed by |
|---|---|
Analytics query (run / run_page / run_cursor / run_chunked) |
AnalyticsSpec.name |
Dynamic read (run / select), on RoutedDuckDbClient only |
DynamicReadSpec.name |
Notes¶
- Query-only. Writing/maintaining tables (Iceberg/Delta compaction, ingest) is an ETL/ops concern, not a domain port — keep it out of the adapter.
- Source styles compose. Register named views (
sources=) and reference them in SQL, or inlineread_parquet('s3://…')in a query — both work. - Iceberg/Delta: point
IcebergSourceat the table's current*.metadata.json(the table-directory form needs aversion-hint.textmany writers omit); for in-place tables read withallow_moved_paths=False. - Concurrency: each query gets its own cursor, so parallel reads don't
serialize. Cap parallelism with
DuckDbConfig(max_concurrent_queries=...). - Cursor pagination is offset-based; a full terminal page reports
has_moreuntil the next (short) page confirms the end.