Skip to the content.

OLAP workflows

DuckDB exposes analytics-friendly table functions like read_parquet and read_csv_auto. The helpers in duckdb_sqlalchemy.olap make these easy to use with SQLAlchemy.

from sqlalchemy import select
from duckdb_sqlalchemy import read_parquet, read_csv_auto

parquet = read_parquet("data/events.parquet", columns=["event_id", "ts"])
stmt = select(parquet.c.event_id, parquet.c.ts)

csv = read_csv_auto("data/events.csv", columns=["event_id", "ts"])
stmt = select(csv.c.event_id, csv.c.ts)

Explicit CSV settings

Use read_csv when you need to control parsing options:

from duckdb_sqlalchemy import read_csv

csv = read_csv(
    "data/events.csv",
    columns=["event_id", "ts"],
    header=True,
    delim="|",
)
stmt = select(csv.c.event_id, csv.c.ts)

Pass columns as a mapping to set DuckDB column types instead of letting DuckDB detect them. The mapping is sent as DuckDB’s columns parameter, and its keys also name the SQLAlchemy columns. The values are DuckDB type names, not SQLAlchemy types. This works with read_csv and read_csv_auto:

csv = read_csv(
    "data/events.csv",
    columns={"event_id": "BIGINT", "ts": "TIMESTAMP"},
    header=True,
)
stmt = select(csv.c.event_id, csv.c.ts)

Other table functions

Use table_function for any DuckDB table function that does not have a helper:

from duckdb_sqlalchemy import table_function

parquet = table_function(
    "read_parquet",
    "data/partitioned/events/*.parquet",
    columns=["event_id", "ts"],
    hive_partitioning=True,
)
stmt = select(parquet.c.event_id, parquet.c.ts)

Storage metadata

Use pragma_storage_info to inspect DuckDB table storage through SQLAlchemy:

from sqlalchemy import select
from duckdb_sqlalchemy import pragma_storage_info

storage = pragma_storage_info("events")
stmt = select(storage.c.column_name, storage.c.segment_type, storage.c.compression)

The helper names DuckDB’s released columns by default. include_segment_info=True is passed to DuckDB as a named argument. DuckDB 1.5.x releases reject it with “Invalid named parameter”; only DuckDB development builds accept it.

Quack remote queries

DuckDB 1.5.3 ships Quack as a core extension. Use quack_query for stateless remote queries and name the returned columns for SQLAlchemy:

from sqlalchemy import select
from duckdb_sqlalchemy import quack_query

remote = quack_query(
    "quack:localhost",
    "SELECT 42 AS answer",
    columns=["answer"],
    token="MY_QUACK_TOKEN_01234567890ABCDEF",
)
stmt = select(remote.c.answer)

MotherDuck metadata

MotherDuck exposes table functions for account and Dive metadata. The helpers name the released columns so they are available through SQLAlchemy:

from sqlalchemy import select
from duckdb_sqlalchemy import md_access_tokens, md_list_dives, md_user_info

user_info = md_user_info()
user_stmt = select(
    user_info.c.user_id,
    user_info.c.username,
    user_info.c.org_id,
    user_info.c.org_name,
    user_info.c.org_type,
)

dives = md_list_dives()
dives_stmt = select(
    dives.c.id,
    dives.c.title,
    dives.c.status,
    dives.c.status_applies_to_version,
    dives.c.required_resources,
)

tokens = md_access_tokens()
tokens_stmt = select(tokens.c.token_name, tokens.c.token_type, tokens.c.expire_at)

md_user_info().c.region is available with DuckDB 1.5.3 and newer. Older MotherDuck clients expose the other five columns.

MotherDuck Guides

MotherDuck Guides are markdown documents that capture data context for agents. The Guide helpers name each table function’s released columns and bind named arguments through SQLAlchemy:

from sqlalchemy import select
from duckdb_sqlalchemy import md_create_guide, md_get_guide, md_list_guides

guides = md_list_guides(topic="revenue", limit=10)
list_stmt = select(guides.c.id, guides.c.title, guides.c.current_version)

guide = md_get_guide(id="00000000-0000-0000-0000-000000000000")
read_stmt = select(guide.c.title, guide.c.content)

created = md_create_guide(title="Metric definitions", content="# Revenue\n...")
create_stmt = select(created.c.id, created.c.current_version)

The same module exposes md_update_guide, md_update_guide_metadata, md_set_guide_access, md_list_guide_versions, and md_delete_guide. Guide functions require a MotherDuck client version that exposes them; they are available with DuckDB 1.5.5.

MotherDuck AI classification

The prompt_jev helper supports the function’s positional input and instructions plus named choice, score, noul, multiple_choice, questions, box, and batch_size arguments:

from sqlalchemy import select
from duckdb_sqlalchemy import prompt_jev

stmt = select(
    prompt_jev(
        "I was charged twice",
        "Which team should handle this?",
        choice=["billing", "technical", "sales"],
    ).label("routing")
)

multiple_choice returns every criterion that applies instead of a single choice. The input can also be a list of images, each a dict with name, image (bytes) and an optional format. box locates an object in an image and returns its bounding box:

with open("receipt.png", "rb") as file:
    images = [{"name": "receipt.png", "image": file.read()}]

stmt = select(prompt_jev(images, box="the total amount").label("total"))

batch_size is not supported with image input. The other arguments are combined as the function reference describes, so box cannot be used with instructions or criteria. multiple_choice, box and image input need the MotherDuck extension for DuckDB 1.5.6. Older clients reject them when the statement binds.

MotherDuck currently limits prompt_jev to the regions listed in its function reference. The classification runs remotely and may incur usage charges.

MotherDuck Dives as code

Dive helpers expose the SQL functions MotherDuck provides for managing Dives from a SQL client or deployment script:

from sqlalchemy import select
from duckdb_sqlalchemy import (
    md_create_dive,
    md_get_dive,
    md_list_dive_versions,
    md_update_dive_content,
    md_update_dive_status,
)

created = md_create_dive(
    title="Sales overview",
    content="export default function Dive() { return null }",
    api_version=1,
    required_resources=[{"url": "md:analytics", "alias": "analytics"}],
)
create_stmt = select(created.c.id, created.c.version_id)

dive = md_get_dive(id="00000000-0000-0000-0000-000000000000")
dive_stmt = select(dive.c.title, dive.c.status, dive.c.content)

updated = md_update_dive_content(
    id="00000000-0000-0000-0000-000000000000",
    content="export default function Dive() { return null }",
)
update_stmt = select(updated.c.version, updated.c.storage_url)

status = md_update_dive_status(
    id="00000000-0000-0000-0000-000000000000",
    status="endorsed",
    version=2,
)
status_stmt = select(status.c.id, status.c.status, status.c.status_set_by)

versions = md_list_dive_versions(id="00000000-0000-0000-0000-000000000000")
versions_stmt = select(versions.c.version, versions.c.created_at)

Metadata, version, and delete helpers:

from duckdb_sqlalchemy import (
    md_delete_dive,
    md_get_dive_version,
    md_update_dive_metadata,
)

renamed = md_update_dive_metadata(
    id="00000000-0000-0000-0000-000000000000",
    title="Sales overview (EMEA)",
    description="Weekly revenue by region",
)
rename_stmt = select(renamed.c.title, renamed.c.description)

version = md_get_dive_version(id="00000000-0000-0000-0000-000000000000", version=2)
version_stmt = select(version.c.version, version.c.content)

deleted = md_delete_dive(id="00000000-0000-0000-0000-000000000000")
delete_stmt = select(deleted.c.success)

md_update_dive_metadata changes the title or description without creating a new version. md_delete_dive removes the Dive and its version history when the statement runs.

MotherDuck Flights

MotherDuck also exposes preview table functions for Flight metadata. The read-only helpers are useful for listing Flights, runs, logs, and versions:

from sqlalchemy import select
from duckdb_sqlalchemy import (
    md_get_flight_logs,
    md_get_flight_run,
    md_list_flight_runs,
    md_list_flight_versions,
    md_list_flights,
)

flights = md_list_flights(limit=10)
flights_stmt = select(flights.c.flight_id, flights.c.flight_name, flights.c.status)

runs = md_list_flight_runs(
    flight_id="00000000-0000-0000-0000-000000000000",
    limit=10,
)
runs_stmt = select(runs.c.run_number, runs.c.status, runs.c.config, runs.c.started_at)

run = md_get_flight_run(
    flight_id="00000000-0000-0000-0000-000000000000",
    run_number=1,
)
run_stmt = select(run.c.run_number, run.c.status, run.c.config, run.c.started_at)

logs = md_get_flight_logs(
    flight_id="00000000-0000-0000-0000-000000000000",
    run_number=1,
)
logs_stmt = select(logs.c.line_number, logs.c.reported_at, logs.c.line)

versions = md_list_flight_versions(
    flight_id="00000000-0000-0000-0000-000000000000"
)
versions_stmt = select(
    versions.c.flight_version,
    versions.c.created_at,
    versions.c.max_runtime_sec,
)

To fetch one Flight or one version, use md_get_flight and md_get_flight_version:

from duckdb_sqlalchemy import md_get_flight, md_get_flight_version

flight = md_get_flight(flight_id="00000000-0000-0000-0000-000000000000")
flight_stmt = select(flight.c.flight_name, flight.c.schedule_cron, flight.c.status)

version = md_get_flight_version(
    flight_id="00000000-0000-0000-0000-000000000000",
    version_number=1,
)
version_stmt = select(
    version.c.flight_version,
    version.c.source_code,
    version.c.requirements_txt,
)

md_list_flight_versions does not return source_code or requirements_txt on DuckDB 1.5.5 and newer. Read them with md_get_flight_version.

Columns by DuckDB version

The MotherDuck extension for each DuckDB release returns a slightly different set of columns, and selecting a column that the installed extension does not return fails with a binder error. The md_* helpers therefore name the columns that the extension for your installed DuckDB version returns:

Column Helpers DuckDB version
required_resources md_list_dives 1.5.2+
status, status_changed_at, status_set_by, status_applies_to_version Dive summary and detail helpers 1.5.4+
max_runtime_sec Flight version helpers 1.5.4+
owner_name md_list_flights, md_get_flight, md_create_flight, md_update_flight 1.5.5+
line_number, reported_at, line md_get_flight_logs (a single logs column before 1.5.5) 1.5.5+
instance_type md_list_flight_versions, md_get_flight_version 1.5.6+

Pass columns= to name the columns yourself, for example when you run a newer MotherDuck extension than your DuckDB version implies.

Mutating Flight functions are available as helpers too: md_create_flight, md_update_flight, md_delete_flight, md_run_flight, and md_cancel_flight_run. They only execute when the SQLAlchemy statement is run. On DuckDB 1.5.6, md_create_flight and md_update_flight accept instance_type (for example instance_type="F8") to size the Flight runner. MotherDuck validates the value and your plan. md_run_flight accepts a config map to override a Flight’s config for that run only; run result helpers expose the effective config column. Config map keys must be strings, non-empty, and cannot contain = or NUL bytes. Config values must be strings or None, and cannot contain NUL bytes.

The older noun-style md_flights, md_flight_runs, md_flight_logs, and md_flight_versions helpers remain as deprecated compatibility aliases for the verb-style functions above. The md_*job* helper names also remain as deprecated compatibility aliases while preserving legacy job_* column access where possible. The deprecated md_flight_logs and md_job_run_logs helpers also preserve their historical logs column, which is the current line-by-line line result on DuckDB 1.5.5 and newer.

Arrow results

For large reads, you can request Arrow tables directly:

from pyarrow import Table as ArrowTable
from sqlalchemy import select

with engine.connect().execution_options(duckdb_arrow=True) as conn:
    result = conn.execute(select(parquet.c.event_id, parquet.c.ts))
    table = result.arrow  # or result.all()
    assert isinstance(table, ArrowTable)

Notes:

Streaming reads

For large result sets, combine stream_results with a larger arraysize:

with engine.connect().execution_options(stream_results=True, duckdb_arraysize=10_000) as conn:
    result = conn.execute(select(parquet.c.event_id, parquet.c.ts))
    for row in result:
        ...

duckdb_arraysize maps to the DBAPI cursor arraysize that fetchmany() uses.

Bulk writes

For large INSERT executemany workloads, the dialect can register a pandas/Arrow object and run INSERT INTO ... SELECT ... internally. Control the threshold with duckdb_copy_threshold:

rows = [{"event_id": 1, "ts": "2024-01-01"}, {"event_id": 2, "ts": "2024-01-02"}]
with engine.begin() as conn:
    conn.execution_options(duckdb_copy_threshold=10000)
    conn.execute(events.insert(), rows)

The fast path applies with default engine settings to plain INSERTs without SQL defaults or explicit VALUES. It registers an Arrow table when pyarrow is installed, otherwise a pandas DataFrame (integer columns with NULLs use nullable Int64). If neither is available, the dialect falls back to regular executemany. The bulk-register path is skipped when RETURNING or ON CONFLICT is in use.

SQL-side bind expressions and MAP/list-of-MAP columns also retain ordinary execution so the batch size cannot change their values or conversion behavior.

On SQLAlchemy 2.x you can also tune multi-row INSERT batching with insertmanyvalues_page_size (defaults to 1000). The older duckdb_insertmanyvalues_page_size alias still works but is deprecated.

COPY helpers

Use COPY to load files directly into DuckDB without row-wise inserts:

from duckdb_sqlalchemy import copy_from_parquet, copy_from_csv

with engine.begin() as conn:
    copy_from_parquet(conn, "events", "data/events.parquet")
    copy_from_csv(conn, "events", "data/events.csv", header=True)

For safety, string table names and COPY option keys must be identifiers. Dotted paths like schema.events are supported, but SQL fragments are rejected. Column names are quoted on a SQLAlchemy connection, so names such as "event type" work.

If you need quoted or mixed-case table names, pass a SQLAlchemy Table object instead of a plain string so SQLAlchemy handles quoting.

For row iterables, you can stream to a temporary CSV in chunks:

from duckdb_sqlalchemy import copy_from_rows

rows = ({"id": i, "name": f"user-{i}"} for i in range(1_000_000))
with engine.begin() as conn:
    copy_from_rows(conn, "users", rows, columns=["id", "name"], chunk_size=100_000)

copy_from_rows writes plain scalar values. It raises TypeError for bytes, lists, tuples, dicts and timedelta values, because CSV cannot represent them faithfully. Use insert_from_arrow, copy_from_parquet or a regular INSERT for BLOB, LIST, STRUCT, MAP and INTERVAL columns.

Missing mapping keys become NULL by default, preserving sparse-row behavior. Use strict=True to require every selected key in every mapping row. A missing key raises ValueError identifying the zero-based row index and column name. Explicit None is still valid. Earlier chunks belong to the caller’s transaction, so roll back the transaction if a later row fails validation. Temporary CSV files are closed and removed on success or failure, including header/flush failures.

Export a query to Parquet

Use copy_to_parquet to export a SQLAlchemy select() directly through DuckDB, without fetching the query’s rows into Python:

from pathlib import Path
from sqlalchemy import bindparam, select
from duckdb_sqlalchemy import copy_to_parquet

query = select(events.c.event_id, events.c.ts).where(
    events.c.event_id.in_(bindparam("event_ids", expanding=True))
)
with engine.connect() as conn:
    result = copy_to_parquet(
        conn,
        query,
        Path("selected-events.parquet"),
        parameters={"event_ids": [1, 2]},
        compression="zstd",
    )
    exported_rows = result.scalar_one()

The helper accepts SQLAlchemy Select and compound selects such as union_all(). Pass one parameter mapping through parameters; normal typed bind processing and expanding IN parameters are preserved. Raw SQL strings, text() statements, and inserts/updates/deletes are not accepted. SQLAlchemy expressions remain trusted application code.

COPY options such as compression are passed as keyword arguments. The format is always Parquet and cannot be overridden. Paths may be strings or Path objects and are escaped as SQL literals for compatibility with older DuckDB versions. The helper is tested with DuckDB 1.3.0 and 1.5.6 and SQLAlchemy 2.0; older DuckDB versions are not verified for this workflow.

File writes follow DuckDB COPY semantics: a single-file export replaces an existing destination, and rolling back the SQL transaction does not undo the file write. Use a distinct destination when retaining a previous export matters. Multi-file options such as partition_by have their own DuckDB append/overwrite behavior. The destination must be accessible to the executing DuckDB instance; remote storage needs the relevant filesystem configuration. This example is verified with local DuckDB, not MotherDuck remote storage.

Export a partitioned snapshot

Use the existing COPY options to split a filtered query into a Hive partitioned Parquet dataset. The destination is a directory; partition columns must appear in the query’s output:

from pathlib import Path
from sqlalchemy import bindparam, select
from duckdb_sqlalchemy import copy_to_parquet, read_parquet

# Choose a new directory for each snapshot.
destination = Path("snapshots/events-2026-09-21")
destination.parent.mkdir(parents=True, exist_ok=True)
query = select(events.c.event_id, events.c.region).where(
    events.c.event_id >= bindparam("minimum_id")
)
with engine.connect() as conn:
    count = copy_to_parquet(
        conn,
        query,
        destination,
        parameters={"minimum_id": 100},
        partition_by=["region"],
        compression="zstd",
    ).scalar_one()
    rows = []
    if count:
        snapshot = read_parquet(
            str(destination / "**" / "*.parquet"),
            columns=["event_id", "region"],
            hive_partitioning=True,
        )
        rows = conn.execute(select(snapshot.c.event_id, snapshot.c.region)).all()

Files live under directories such as region=eu/. Hive partitioning restores the partition columns from those paths when scanning. An empty selection writes no Parquet files, so check the COPY count before scanning the glob.

Run the self-contained partitioned Parquet example with uv run python examples/partitioned_parquet.py from the repository. It creates temporary sample data, exports a bound query, and verifies the recovered rows and partition directories. This local workflow is verified with DuckDB 1.3.0 / SQLAlchemy 2.0.0 and DuckDB 1.5.6 / SQLAlchemy 2.0.52.

By default, a second export to a nonempty destination fails. Prefer a distinct directory per snapshot. append=True adds files; it does not update or deduplicate rows, so repeating a batch can duplicate data. File writes survive transaction rollback and are not an atomic dataset publication mechanism. See DuckDB’s partitioned write documentation for overwrite and append options. Remote storage is not tested by this recipe.

Preserve string partition keys

Hive readers infer partition types from directory names. A string key such as "123" or "2026-09-23" can return as an integer or date. Set hive_types when reading a snapshot to preserve those identifiers as strings:

snapshot = read_parquet(
    "snapshots/events/**/*.parquet",
    columns=["id", "region"],
    hive_partitioning=True,
    hive_types={"region": "VARCHAR"},
)
rows = conn.execute(select(snapshot.c.id, snapshot.c.region)).all()

columns names the SQLAlchemy columns. It does not set the types DuckDB infers. Use DuckDB type names in hive_types. See Hive partition types.

Join a snapshot to a live table

A Parquet scan is a SQLAlchemy FROM element, so a saved event snapshot can still be filtered and joined to a current local table. Include the join key in the exported query:

from sqlalchemy import bindparam, or_, select

snapshot = read_parquet(
    "snapshots/events/**/*.parquet",
    columns=["id", "account_id", "region"],
    hive_partitioning=True,
    hive_types={"region": "VARCHAR"},
)
report = (
    select(snapshot.c.id, snapshot.c.region, accounts.c.tier)
    .join(accounts, snapshot.c.account_id == accounts.c.id)
    .where(snapshot.c.id >= bindparam("minimum_id"))
    .where(or_(snapshot.c.region == bindparam("region"), snapshot.c.region.is_(None)))
)
rows = conn.execute(report, {"minimum_id": 1, "region": "123"}).all()

Here accounts is a SQLAlchemy table in the same DuckDB connection. The partition key "123" stays a string because of hive_types. A NULL region reads back as None; DuckDB 1.3 writes its directory as region=NULL, while newer versions use region=__HIVE_DEFAULT_PARTITION__. The account columns reflect the live table at query time. See the runnable snapshot join example, which also updates an account after export and verifies the joined result.

ATTACH for multi-database analytics

DuckDB can query across multiple databases in a single session:

from sqlalchemy import create_engine, text

conn = create_engine("duckdb:///local.duckdb").connect()
conn.execute(text("ATTACH 'analytics.duckdb' AS analytics"))
rows = conn.execute(text("SELECT * FROM analytics.events LIMIT 10")).fetchall()

Quack remotes can be attached the same way when a Quack server is available:

conn.execute(
    text(
        "ATTACH 'quack:localhost' AS remote_db "
        "(TOKEN 'MY_QUACK_TOKEN_01234567890ABCDEF')"
    )
)
rows = conn.execute(text("SELECT * FROM remote_db.events LIMIT 10")).fetchall()