The analytical engine that lives next to your Parquet.
Data Engineering Module 4 Modern LakehouseProduced Parquet partitions (L02), validated by Great Expectations (L03), orchestrated by Airflow (L04–L06), and modelled by dbt (L08). You have data at rest in a well-structured directory.
Flips the script: instead of pushing data into a database, the engine reads Parquet (and later Iceberg) in place. Same data, no copy, no load step.
import duckdb.pip install duckdb. No daemon, no port, no pg_ctl start.import duckdb
con = duckdb.connect(":memory:")
con.execute("""
SELECT type, count(*) AS n
FROM read_parquet('data/parquet/date=*/*.parquet',
hive_partitioning=true)
GROUP BY type
ORDER BY n DESC
""").fetchall()
No LOAD, no COPY, no DDL. DuckDB reads the partition files in place and respects the Hive-style date=YYYY-MM-DD/ directory layout for predicate pushdown.
SQL: WHERE date = '2024-01-01'
|
v
DuckDB partition discovery
(reads directory listing only)
|
v
Opens ONLY date=2024-01-01/*.parquet
|
v
Reads Parquet row-group statistics
(min/max per column per chunk)
|
v
Decompresses ONLY matching pages
for the columns in SELECT
The whole-file-scan alternative reads ∼1000× more bytes. Partition pruning + row-group skipping + column projection stack together.
| Need | SQLite | pandas | DuckDB |
|---|---|---|---|
| OLTP (writes, transactions) | yes | no | not the focus |
| OLAP (GROUP BY, joins on millions of rows) | slow | depends on RAM | yes |
| Reads Parquet natively | no | with read_parquet | yes, with pushdown |
| Vector / columnar engine | no | partial | yes |
| One-line install | yes | yes | yes |
The takeaway: DuckDB is the SQLite-of-analytics. Same install footprint, OLAP semantics.
Every Airflow task spins up its own DuckDB connection and re-discovers the partitions. Schema changes require updating every task individually. Tests use a different code path from production.
A thin query.py helper exposes open_pipeline_db() returning a connection with the events view pre-registered. Every task imports the helper; tests point the same helper at fixtures.
from sample_pipeline.duckdb.query import open_pipeline_db
with open_pipeline_db() as con:
rows = con.execute(
"SELECT type, count(*) FROM events GROUP BY type"
).fetchall()
The same open_pipeline_db() pattern is used in count_by_type.py and top_repos.py in the sample pipeline. One view definition, tested once, reused everywhere.
.duckdb file.CREATE TABLE that persists across runs unless you point it at a .duckdb file. The L10 Iceberg lesson is the persistence answer.A single .duckdb file supports one writer at a time; multiple readers are fine. In Airflow you typically run pipeline scans against the in-memory connection (no .duckdb file at all) so concurrent task instances never contend. The persistent .duckdb form is for analyst-facing dashboards or long-lived local exploratory sessions.
The in-memory pattern used in query.py is always safe in multi-worker Airflow: each task process gets its own Python interpreter and its own DuckDB connection object.
sample-pipeline/duckdb/ with query.py, count_by_type.py, top_repos.py — the CLI scripts you run in the lab today.query_duckdb.py). Same engine, richer table format.open_pipeline_db() — one helper, one view definition, used identically in CLI scripts, tests, and Airflow tasks.