DuckDB and Polars execution boundary
Choose one engine to own each stage. DuckDB owns relational SQL, multi-source
joins, and file scans that SQL expresses clearly. Polars owns reusable typed
expression pipelines and dataframe-native transformations. Cross once at a
deliberate consumer boundary rather than alternating after every operation.
Workflow
- Inspect DuckDB and Polars versions, source size/format, desired output grain,
schema, null/timestamp/nested types, ordering, and final consumer.
- Decide whether files remain the shared boundary or whether an in-memory
Arrow-compatible transfer is justified. Prefer Parquet for reusable or
process-crossing artifacts.
- Keep source objects alive while DuckDB replacement scans or registered views
refer to them. Use explicit registration when lifetime or naming must be
obvious; unregister when a long-lived connection would retain stale state.
- Materialize exactly once when crossing to an eager consumer. Do not call
.collect(), .pl(), .arrow(), or dataframe conversion repeatedly in a
loop merely to continue the pipeline in the other engine.
- Validate row count, grain, column names/order, dtypes, nulls, timestamps,
nested values, dictionary/categorical semantics, and ordering after transfer.
Decision rules
- If DuckDB SQL can scan the original files and produce the final relational
result, keep the work there and convert only for the Polars consumer.
- If Polars transformation is the reusable product, scan/transform in Polars
and expose Arrow/Parquet to DuckDB only for a SQL stage that adds value.
- Treat conversion as potentially copying or coercing unless current APIs and
buffers prove otherwise. Zero-copy is an optimization claim, not a default.
- SQL result order is undefined without
ORDER BY; dataframe transfer does not
create a durable ordering guarantee.
import duckdb
import polars as pl
source = pl.scan_parquet("events/*.parquet")
prepared = source.filter(pl.col("status") == "ok").collect()
with duckdb.connect() as connection:
connection.register("prepared", prepared)
result = connection.sql(
"select customer_id, count(*) as n from prepared group by customer_id"
).pl()
Verify the installed conversion APIs and add a round-trip contract test. Read
ownership and lifetime, schema transfer,
and verification.
1---2name: duckdb-polars-boundaries3description: >-4 Use when Python analytical code crosses between DuckDB SQL/relations and5 Polars DataFrame/LazyFrame objects, especially for Arrow transfer, lazy versus6 eager execution, registration lifetime, schema conversion, result ownership,7 or deciding which engine owns a stage. Do not use for DuckDB-only SQL or8 Polars-only transformations.9argument-hint: "[source, stage ownership, transfer format, and consumer]"10---1112# DuckDB and Polars execution boundary1314Choose one engine to own each stage. DuckDB owns relational SQL, multi-source15joins, and file scans that SQL expresses clearly. Polars owns reusable typed16expression pipelines and dataframe-native transformations. Cross once at a17deliberate consumer boundary rather than alternating after every operation.1819## Workflow20211. Inspect DuckDB and Polars versions, source size/format, desired output grain,22 schema, null/timestamp/nested types, ordering, and final consumer.232. Decide whether files remain the shared boundary or whether an in-memory24 Arrow-compatible transfer is justified. Prefer Parquet for reusable or25 process-crossing artifacts.263. Keep source objects alive while DuckDB replacement scans or registered views27 refer to them. Use explicit registration when lifetime or naming must be28 obvious; unregister when a long-lived connection would retain stale state.294. Materialize exactly once when crossing to an eager consumer. Do not call30 `.collect()`, `.pl()`, `.arrow()`, or dataframe conversion repeatedly in a31 loop merely to continue the pipeline in the other engine.325. Validate row count, grain, column names/order, dtypes, nulls, timestamps,33 nested values, dictionary/categorical semantics, and ordering after transfer.3435## Decision rules3637- If DuckDB SQL can scan the original files and produce the final relational38 result, keep the work there and convert only for the Polars consumer.39- If Polars transformation is the reusable product, scan/transform in Polars40 and expose Arrow/Parquet to DuckDB only for a SQL stage that adds value.41- Treat conversion as potentially copying or coercing unless current APIs and42 buffers prove otherwise. Zero-copy is an optimization claim, not a default.43- SQL result order is undefined without `ORDER BY`; dataframe transfer does not44 create a durable ordering guarantee.4546```python47import duckdb48import polars as pl4950source = pl.scan_parquet("events/*.parquet")51prepared = source.filter(pl.col("status") == "ok").collect()5253with duckdb.connect() as connection:54 connection.register("prepared", prepared)55 result = connection.sql(56 "select customer_id, count(*) as n from prepared group by customer_id"57 ).pl()58```5960Verify the installed conversion APIs and add a round-trip contract test. Read61[ownership and lifetime](references/ownership.md), [schema transfer](references/schema.md),62and [verification](references/verification.md).