Skip to content

DuckDB

DuckDBIOHandler

Persists asset outputs as tables in a DuckDB database or a DuckLake catalog.

from rivers.integrations.duckdb import DuckDBIOHandler, DuckDBResource

io = DuckDBIOHandler(
    resource=DuckDBResource(database="warehouse.duckdb"),
    mode="overwrite",
)

Constructor:

Parameter Type Default Description
resource DuckDBResource required Opens the connections. Each asset becomes the table {schema_name}.{asset_name}.
schema_name str "main" Schema for the tables.
mode DuckDBWriteMode OVERWRITE OVERWRITE or APPEND. The strings "overwrite" and "append" work too.

Supported types:

Type Write Read
duckdb.DuckDBPyRelation yes lazy
pyarrow.Table yes eager
pyarrow.RecordBatchReader yes lazy
arro3 RecordBatchReader yes no
polars.DataFrame yes eager
polars.LazyFrame yes lazy
pandas.DataFrame yes eager

Lazy inputs read in one thread share DuckDBResource.shared_connection(), so a step can join them.

Asset metadata overrides:

These metadata keys override handler defaults per-asset:

Key Type Description
duckdb/schema str Schema override.
duckdb/table str Table name override (default: the asset name).
duckdb/mode str Write mode override.
duckdb/partition_expr str \| JSON dict Partition column mapping.
duckdb/columns JSON list Column selection for reads.
duckdb/version str DuckLake snapshot for time travel reads. Raises ValueError on a DuckDB database.
ducklake/options JSON dict DuckLake table options, set after each write when they differ from the catalog.

Output metadata:

Key Type Description
duckdb/table str schema.table.
duckdb/num_rows int Total rows in table after write.
duckdb/write_duration_s float Write duration in seconds.
rivers/schema Schema Arrow schema of the written table (needs pyarrow or arro3).
ducklake/snapshot_id int DuckLake only: snapshot of the write, also registered as the data version.

Methods for action bodies

An action receives the asset's resolved handler as ctx.io_handler. These methods expose the same table and partition resolution the write path uses, so a custom action targets exactly the rows the materialize path would have written.

table_name(asset_name, asset_metadata)

Returns the (schema, table) pair, honoring duckdb/schema and duckdb/table overrides in asset_metadata. Pass ctx.asset_name and ctx.asset_metadata.

@rs.action(outcome=rs.Outcome.Unchanged)
@classmethod
def analyze(cls, ctx: rs.ActionContext) -> None:
    schema, table = ctx.io_handler.table_name(ctx.asset_name, ctx.asset_metadata)
    with ctx.io_handler.resource.get_connection() as con:
        con.execute(f'ANALYZE "{schema}"."{table}"')

partition_predicate(asset_metadata, partition)

Returns a SQL predicate covering the partition(s), honoring duckdb/partition_expr. Pass ctx.asset_metadata and ctx.partition.

@rs.action(outcome=rs.Outcome.Unmaterialize)
@classmethod
def delete(cls, ctx: rs.ActionContext) -> None:
    schema, table = ctx.io_handler.table_name(ctx.asset_name, ctx.asset_metadata)
    predicate = ctx.io_handler.partition_predicate(ctx.asset_metadata, ctx.partition)
    with ctx.io_handler.resource.get_connection() as con:
        con.execute(f'DELETE FROM "{schema}"."{table}" WHERE {predicate}')

partition is required: reach this only from a keyed action run. On a non-partitioned asset, delete the whole table instead of building a predicate.


DuckDBAsset

Asset base class with the delete verb built in. Subclass it, define materialize, and delete appears as an action, resolved against the asset's DuckDBIOHandler (its own io_handler or the repository default). Sets kinds = "duckdb".

from rivers.integrations.duckdb import DuckDBAsset

class Orders(DuckDBAsset):
    io_handler = WAREHOUSE

    @classmethod
    def materialize(cls) -> pl.DataFrame: ...
Verb Declaration Behavior
delete Unmaterialize + Exclusive + DownstreamFirst + Optional key Deletes the keyed partition's rows via partition_predicate, or every row without a key — on partitioned assets both forms are valid. From the UI or gRPC, the keyless form needs an explicit whole-asset choice.

A verb requires the asset to resolve a DuckDBIOHandler — anything else fails the step with a TypeError naming the requirement. On a table that does not exist, delete still applies its Unmaterialize outcome, so dangling state clears. A keyed delete on an asset without duckdb/partition_expr fails with a ValueError naming the key. A subclass redefining a verb replaces the built-in.


DuckLakeAsset

DuckDBAsset subclass for DuckLake tables, with optimize and vacuum added. Sets kinds = "ducklake".

from rivers.integrations.duckdb import DuckLakeAsset

class Events(DuckLakeAsset):
    io_handler = LAKE
    metadata = {"ducklake/options": '{"rewrite_delete_threshold": 0.5}'}

    @classmethod
    def materialize(cls) -> pl.DataFrame: ...
Verb Declaration Behavior
optimize Unchanged + Exclusive + Keyless ducklake_flush_inlined_data, ducklake_rewrite_data_files, and ducklake_merge_adjacent_files for the table, with its rewrite_delete_threshold and target_file_size options. Reports rows_flushed, files_rewritten, and files_merged.
vacuum Unchanged + Exclusive + Keyless ducklake_expire_snapshots and ducklake_cleanup_old_files for the whole lake, with its expire_older_than and delete_older_than options. Does nothing when neither is set. Reports snapshots_expired and files_deleted.
delete as DuckDBAsset

The verbs take no config: set table options with the ducklake/options metadata key, and lake-wide options with DuckLake(options=...). A verb requires the handler's resource to have a DuckLake database — otherwise it fails the step with a TypeError. On a table that does not exist yet, optimize reports ActionResult.unchanged().


DuckDBResource

Opens DuckDB connections to a database file, :memory:, MotherDuck, or a DuckLake catalog. Fields are read from DUCKDB_* environment variables when not given.

from rivers.integrations.duckdb import DuckDBResource

warehouse = DuckDBResource(database="warehouse.duckdb", extensions=["spatial"])

Constructor:

Parameter Type Default Description
database str \| DuckLake ":memory:" A file path, ":memory:", "md:<database>" for MotherDuck, or a DuckLake catalog. In DUCKDB_DATABASE, a DuckLake is a JSON object.
connection_config dict[str, str \| bool \| int \| float \| list[str]] {} DuckDB settings passed to duckdb.connect(config=...).
extensions list[str] [] Extensions to install and load on each connection.
secrets dict[str, dict[str, str \| int \| bool]] {} Secret name to CREATE SECRET parameters, created on each connection.

Opening a file that another process holds is retried 10 times, with a delay of 0.1 s that doubles each time. After the last try the duckdb.IOException is raised; for a DuckLake catalog in a DuckDB file, the error says that the catalog allows one process.

Methods

get_connection()

Context manager: a connection that is closed when the block exits. Relations made from it stop working after the block.

with warehouse.get_connection() as con:
    con.execute("CREATE TABLE users AS FROM 'users.parquet'")

connect()

Returns a new connection. The caller closes it, or a relation keeps it open. With a DuckLake database, it is a cursor whose default database is the catalog.

shared_connection()

Returns the calling thread's connection for relations that must be combined: DuckDB joins relations only when they come from one connection. The connection closes when the last relation made from it is gone.

@rs.Asset(io_handler=io)
def enriched(orders: duckdb.DuckDBPyRelation, warehouse: DuckDBResource) -> duckdb.DuckDBPyRelation:
    regions = warehouse.shared_connection().sql("FROM 'regions.csv'")
    return orders.join(regions, "region_id")

teardown()

Releases the DuckLake root connection. The catalog closes when the last cursor or relation made from it is gone.


DuckLake

A DuckLake catalog, attached once per process to every connection of a DuckDBResource.

from rivers.integrations.duckdb import DuckDBResource, DuckLake

lake = DuckDBResource(
    database=DuckLake(
        metadata="postgres:dbname=lake host=pg",
        data_path="s3://bucket/lake/",
        options={"expire_older_than": "7 days"},
    )
)

Attributes:

Attribute Type Default Description
metadata str required The catalog database: a DuckDB file path (one process), "postgres:dbname=lake host=pg", or "sqlite:meta.sqlite".
data_path str \| None None Where DuckLake writes Parquet files.
name str "lake" The catalog name in SQL.
attach_options dict[str, str \| int \| bool] {} Other ATTACH options, e.g. {"METADATA_SCHEMA": "lake"}.
options dict[str, str \| int \| float \| bool] {} Lake-wide DuckLake options. Each one that differs from the catalog is set when the catalog is attached.