Skip to content

DuckLake

Setup

pluginlake.core.ducklake.setup

DuckLake database setup and connection management.

ensure_database(settings)

Create the DuckLake PostgreSQL database if it does not exist.

Connects to the default postgres database to check for and optionally create the target database. Uses autocommit because CREATE DATABASE cannot run inside a transaction.

Parameters:

Name Type Description Default
settings DuckLakeSettings

DuckLake settings with PostgreSQL connection details.

required
Source code in src/pluginlake/core/ducklake/setup.py
def ensure_database(settings: DuckLakeSettings) -> None:
    """Create the DuckLake PostgreSQL database if it does not exist.

    Connects to the default ``postgres`` database to check for and
    optionally create the target database. Uses autocommit because
    CREATE DATABASE cannot run inside a transaction.

    Args:
        settings: DuckLake settings with PostgreSQL connection details.
    """
    conn = psycopg2.connect(
        host=settings.pg_host,
        port=settings.pg_port,
        user=settings.pg_user,
        password=settings.pg_password,
        dbname="postgres",
    )
    conn.autocommit = True

    try:
        with conn.cursor() as cur:
            cur.execute(
                "SELECT 1 FROM pg_database WHERE datname = %s",
                (settings.pg_db,),
            )
            if cur.fetchone():
                logger.info("DuckLake database '%s' already exists.", settings.pg_db)
                return

            cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(settings.pg_db)))
            logger.info("Created DuckLake database '%s'.", settings.pg_db)
    finally:
        conn.close()

create_connection(settings, backend=None)

Create a DuckDB connection with DuckLake attached.

Installs and loads the DuckLake extension, applies any storage-specific DuckDB configuration, and attaches the DuckLake catalog.

Parameters:

Name Type Description Default
settings DuckLakeSettings

DuckLake settings with PostgreSQL connection details.

required
backend StorageBackend | None

Storage backend to use. If None, one is resolved from settings.

None

Returns:

Type Description
DuckDBPyConnection

A ready-to-use DuckDB connection with the ducklake catalog attached.

Source code in src/pluginlake/core/ducklake/setup.py
def create_connection(
    settings: DuckLakeSettings,
    backend: StorageBackend | None = None,
) -> duckdb.DuckDBPyConnection:
    """Create a DuckDB connection with DuckLake attached.

    Installs and loads the DuckLake extension, applies any storage-specific
    DuckDB configuration, and attaches the DuckLake catalog.

    Args:
        settings: DuckLake settings with PostgreSQL connection details.
        backend: Storage backend to use. If None, one is resolved from settings.

    Returns:
        A ready-to-use DuckDB connection with the ``ducklake`` catalog attached.
    """
    if backend is None:
        backend = _resolve_storage_backend(settings)

    conn = duckdb.connect()

    conn.execute("INSTALL ducklake")
    conn.execute("LOAD ducklake")

    backend.configure_duckdb(conn)

    attach_query = (
        f"ATTACH 'ducklake:postgres:{settings.pg_connection_string}' "
        f"AS ducklake (DATA_PATH '{backend.get_base_path()}', OVERRIDE_DATA_PATH TRUE)"
    )

    max_retries = 3
    for attempt in range(max_retries):
        try:
            conn.execute(attach_query)
            break
        except duckdb.Error as exc:
            if "already exists" in str(exc) and attempt < max_retries - 1:
                logger.warning("DuckLake catalog init race detected (attempt %d), retrying...", attempt + 1)
                time.sleep(1)
                conn.close()
                conn = duckdb.connect()
                conn.execute("INSTALL ducklake")
                conn.execute("LOAD ducklake")
                backend.configure_duckdb(conn)
            else:
                raise

    _apply_tuning_options(conn, settings)

    logger.info(
        "Attached DuckLake catalog (db=%s, data_path=%s).",
        settings.pg_db,
        backend.get_base_path(),
    )
    return conn

setup_ducklake(settings=None)

Run the full DuckLake setup: ensure database exists, then connect.

This is the main entry point called at server startup.

Parameters:

Name Type Description Default
settings DuckLakeSettings | None

DuckLake settings. Loaded from environment if None.

None

Returns:

Type Description
DuckDBPyConnection

A ready-to-use DuckDB connection with the ducklake catalog attached.

Source code in src/pluginlake/core/ducklake/setup.py
def setup_ducklake(
    settings: DuckLakeSettings | None = None,
) -> duckdb.DuckDBPyConnection:
    """Run the full DuckLake setup: ensure database exists, then connect.

    This is the main entry point called at server startup.

    Args:
        settings: DuckLake settings. Loaded from environment if None.

    Returns:
        A ready-to-use DuckDB connection with the ``ducklake`` catalog attached.
    """
    if settings is None:
        settings = DuckLakeSettings()  # type: ignore[missing-argument]

    ensure_database(settings)
    backend = _resolve_storage_backend(settings)
    return create_connection(settings, backend)

IO Manager

pluginlake.core.ducklake.io_manager

DuckLake IO manager for Dagster assets.

DuckLakeIOManager

Bases: IOManager

Dagster IO manager that persists asset outputs to DuckLake.

Resolves each asset key to a catalog.schema.table path: - ["condition_era"] → ducklake.main.condition_era - ["omop", "condition_era"] → ducklake.omop.condition_era - ["raw", "omop", "condition_era"] → ducklake.raw.omop_condition_era

Parameters:

Name Type Description Default
conn DuckDBPyConnection

A DuckDB connection with the ducklake catalog already attached.

required
Source code in src/pluginlake/core/ducklake/io_manager.py
class DuckLakeIOManager(IOManager):
    """Dagster IO manager that persists asset outputs to DuckLake.

    Resolves each asset key to a ``catalog.schema.table`` path:
        - ``["condition_era"]`` → ``ducklake.main.condition_era``
        - ``["omop", "condition_era"]`` → ``ducklake.omop.condition_era``
        - ``["raw", "omop", "condition_era"]`` → ``ducklake.raw.omop_condition_era``

    Args:
        conn: A DuckDB connection with the ``ducklake`` catalog already attached.
    """

    def __init__(
        self,
        conn: "duckdb.DuckDBPyConnection",
    ) -> None:
        """Initialize with an open DuckDB connection.

        Args:
            conn: A DuckDB connection with the ``ducklake`` catalog attached.
        """
        self._conn = conn

    def table_ref(self, asset_key_path: list[str]) -> str:
        """Derive a fully qualified DuckLake table reference from an asset key.

        First segment becomes the schema, remaining segments are joined
        with ``_`` to form the table name. Single-segment keys use the
        default schema.

        Args:
            asset_key_path: The asset key path segments,
                e.g. ``["condition_era"]`` or ``["omop", "condition_era"]``.

        Returns:
            Fully qualified reference like ``ducklake.omop.condition_era``.
        """
        if len(asset_key_path) == 1:
            schema = DEFAULT_SCHEMA
            table = asset_key_path[0]
        else:
            schema = asset_key_path[0]
            table = "_".join(asset_key_path[1:])

        return f"{CATALOG}.{schema}.{table}"

    def _ensure_schema(self, schema: str) -> None:
        """Create the DuckLake schema if it does not exist.

        Args:
            schema: The schema name to ensure exists.
        """
        self._conn.execute(f"CREATE SCHEMA IF NOT EXISTS {CATALOG}.{schema}")

    def handle_output(self, context: "OutputContext", obj: pl.DataFrame | pl.LazyFrame) -> None:
        """Write a Polars DataFrame or LazyFrame to a DuckLake table.

        Accepts both eager and lazy frames. When a ``pl.LazyFrame`` is
        provided, DuckDB evaluates the query plan internally via the
        native Polars plugin, so data never materializes in Python.

        Ensures the target schema exists, registers the frame with
        DuckDB, and creates or replaces the table.

        Args:
            context: Dagster output context containing the asset key.
            obj: The Polars DataFrame or LazyFrame to persist.
        """
        path = list(context.asset_key.path)
        ref = self.table_ref(path)
        schema = ref.split(".")[1]

        self._ensure_schema(schema)

        self._conn.register("_data", obj)
        self._conn.execute(
            f"CREATE OR REPLACE TABLE {ref} AS SELECT * FROM _data"  # noqa: S608
        )
        self._conn.unregister("_data")

        row_count = self._conn.sql(
            f"SELECT COUNT(*) FROM {ref}"  # noqa: S608
        ).fetchone()
        logger.info("Wrote %d rows to %s.", row_count[0] if row_count else 0, ref)

    def load_input(self, context: "InputContext") -> pl.LazyFrame:
        """Load a DuckLake table as a Polars LazyFrame.

        Uses DuckDB's ``pl(lazy=True)`` to return a lazy frame with
        projection and filter pushdown support. Polars operations
        chained on the result are pushed down to DuckDB at collect
        time, enabling DuckLake file pruning.

        Args:
            context: Dagster input context containing the upstream asset key.

        Returns:
            A lazy view of the table. Call ``.collect()`` to materialize.
        """
        ref = self.table_ref(list(context.asset_key.path))
        result = self._conn.sql(f"SELECT * FROM {ref}").pl(lazy=True)  # noqa: S608

        logger.info("Loaded lazy frame from %s.", ref)

        return result

__init__(conn)

Initialize with an open DuckDB connection.

Parameters:

Name Type Description Default
conn DuckDBPyConnection

A DuckDB connection with the ducklake catalog attached.

required
Source code in src/pluginlake/core/ducklake/io_manager.py
def __init__(
    self,
    conn: "duckdb.DuckDBPyConnection",
) -> None:
    """Initialize with an open DuckDB connection.

    Args:
        conn: A DuckDB connection with the ``ducklake`` catalog attached.
    """
    self._conn = conn

table_ref(asset_key_path)

Derive a fully qualified DuckLake table reference from an asset key.

First segment becomes the schema, remaining segments are joined with _ to form the table name. Single-segment keys use the default schema.

Parameters:

Name Type Description Default
asset_key_path list[str]

The asset key path segments, e.g. ["condition_era"] or ["omop", "condition_era"].

required

Returns:

Type Description
str

Fully qualified reference like ducklake.omop.condition_era.

Source code in src/pluginlake/core/ducklake/io_manager.py
def table_ref(self, asset_key_path: list[str]) -> str:
    """Derive a fully qualified DuckLake table reference from an asset key.

    First segment becomes the schema, remaining segments are joined
    with ``_`` to form the table name. Single-segment keys use the
    default schema.

    Args:
        asset_key_path: The asset key path segments,
            e.g. ``["condition_era"]`` or ``["omop", "condition_era"]``.

    Returns:
        Fully qualified reference like ``ducklake.omop.condition_era``.
    """
    if len(asset_key_path) == 1:
        schema = DEFAULT_SCHEMA
        table = asset_key_path[0]
    else:
        schema = asset_key_path[0]
        table = "_".join(asset_key_path[1:])

    return f"{CATALOG}.{schema}.{table}"

handle_output(context, obj)

Write a Polars DataFrame or LazyFrame to a DuckLake table.

Accepts both eager and lazy frames. When a pl.LazyFrame is provided, DuckDB evaluates the query plan internally via the native Polars plugin, so data never materializes in Python.

Ensures the target schema exists, registers the frame with DuckDB, and creates or replaces the table.

Parameters:

Name Type Description Default
context OutputContext

Dagster output context containing the asset key.

required
obj DataFrame | LazyFrame

The Polars DataFrame or LazyFrame to persist.

required
Source code in src/pluginlake/core/ducklake/io_manager.py
def handle_output(self, context: "OutputContext", obj: pl.DataFrame | pl.LazyFrame) -> None:
    """Write a Polars DataFrame or LazyFrame to a DuckLake table.

    Accepts both eager and lazy frames. When a ``pl.LazyFrame`` is
    provided, DuckDB evaluates the query plan internally via the
    native Polars plugin, so data never materializes in Python.

    Ensures the target schema exists, registers the frame with
    DuckDB, and creates or replaces the table.

    Args:
        context: Dagster output context containing the asset key.
        obj: The Polars DataFrame or LazyFrame to persist.
    """
    path = list(context.asset_key.path)
    ref = self.table_ref(path)
    schema = ref.split(".")[1]

    self._ensure_schema(schema)

    self._conn.register("_data", obj)
    self._conn.execute(
        f"CREATE OR REPLACE TABLE {ref} AS SELECT * FROM _data"  # noqa: S608
    )
    self._conn.unregister("_data")

    row_count = self._conn.sql(
        f"SELECT COUNT(*) FROM {ref}"  # noqa: S608
    ).fetchone()
    logger.info("Wrote %d rows to %s.", row_count[0] if row_count else 0, ref)

load_input(context)

Load a DuckLake table as a Polars LazyFrame.

Uses DuckDB's pl(lazy=True) to return a lazy frame with projection and filter pushdown support. Polars operations chained on the result are pushed down to DuckDB at collect time, enabling DuckLake file pruning.

Parameters:

Name Type Description Default
context InputContext

Dagster input context containing the upstream asset key.

required

Returns:

Type Description
LazyFrame

A lazy view of the table. Call .collect() to materialize.

Source code in src/pluginlake/core/ducklake/io_manager.py
def load_input(self, context: "InputContext") -> pl.LazyFrame:
    """Load a DuckLake table as a Polars LazyFrame.

    Uses DuckDB's ``pl(lazy=True)`` to return a lazy frame with
    projection and filter pushdown support. Polars operations
    chained on the result are pushed down to DuckDB at collect
    time, enabling DuckLake file pruning.

    Args:
        context: Dagster input context containing the upstream asset key.

    Returns:
        A lazy view of the table. Call ``.collect()`` to materialize.
    """
    ref = self.table_ref(list(context.asset_key.path))
    result = self._conn.sql(f"SELECT * FROM {ref}").pl(lazy=True)  # noqa: S608

    logger.info("Loaded lazy frame from %s.", ref)

    return result

ducklake_io_manager(_init_context)

Dagster IO manager factory that sets up DuckLake and returns an IO manager.

Calls :func:setup_ducklake to ensure the database exists and attach the DuckLake catalog, then wraps the connection in a :class:DuckLakeIOManager.

Source code in src/pluginlake/core/ducklake/io_manager.py
@io_manager
def ducklake_io_manager(_init_context: "InitResourceContext") -> DuckLakeIOManager:
    """Dagster IO manager factory that sets up DuckLake and returns an IO manager.

    Calls :func:`setup_ducklake` to ensure the database exists and
    attach the DuckLake catalog, then wraps the connection in a
    :class:`DuckLakeIOManager`.

    """
    conn = setup_ducklake()
    return DuckLakeIOManager(conn)

Settings

pluginlake.core.config

Configuration for the DuckLake data catalog.

DuckLakeSettings

Bases: BaseSettings

DuckLake catalog settings.

All settings can be overridden via environment variables prefixed with DUCKLAKE_.

Source code in src/pluginlake/core/config.py
class DuckLakeSettings(BaseSettings):
    """DuckLake catalog settings.

    All settings can be overridden via environment variables
    prefixed with ``DUCKLAKE_``.
    """

    model_config = SettingsConfigDict(
        env_prefix="DUCKLAKE_",
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    pg_host: str
    pg_port: int = 5432
    pg_user: str
    pg_password: str
    pg_db: str
    catalog_name: str = "lakehouse"
    data_path: str = ".data/lakehouse"

    target_file_size: int | None = None
    parquet_compression: str | None = None
    per_thread_output: bool | None = None

    @property
    def pg_connection_string(self) -> str:
        """Build the libpq connection string for DuckLake metadata."""
        return (
            f"host={self.pg_host} port={self.pg_port} "
            f"dbname={self.pg_db} user={self.pg_user} password={self.pg_password}"
        )

    @property
    def attach_url(self) -> str:
        """Build the full DuckLake ATTACH URL."""
        return f"ducklake:{self.pg_connection_string}"

pg_connection_string property

Build the libpq connection string for DuckLake metadata.

attach_url property

Build the full DuckLake ATTACH URL.