Data tooling: Polars, DuckDB, Arrow¶
At a glance
Priority: P1 · Complexity: ⅖ · Est. time: 2 h · Phase: 2 · Prereqs: Pydantic v2, Performance & profiling You're done when: you can pick between Polars, DuckDB and pandas for a task, write a lazy Polars query and the equivalent DuckDB SQL over Parquet, move data zero-copy between them via Arrow, and use these tools for eval datasets and RAG ingestion metadata.
Why it matters¶
Agent and RAG engineering is data engineering in disguise: eval datasets, trace analysis, chunk metadata, token/cost analytics, feature tables for routing. Row-by-row Python (dicts + Pydantic) is fine for thousands of records and painful for millions. Polars (1.4x as of Sept 2026), DuckDB (1.5.x) and Apache Arrow (PyArrow 25.x) give columnar, multi-threaded, larger-than-RAM-capable processing with a small dependency footprint, and they release the GIL, so they compose well with asyncio and threads (concurrency models). Interviews probe "when would you not use pandas" and "what is Arrow buying you".
Core concepts¶
The three layers¶
flowchart LR
P[Parquet / CSV / JSONL / S3] --> D[DuckDB: SQL engine, in-process]
P --> L[Polars: DataFrame API, lazy optimiser]
D <-->|Arrow, zero-copy| A[Arrow columnar memory]
L <-->|Arrow, zero-copy| A
A --> N[NumPy / pandas / ML libs / Flight / IPC]
- Arrow is the columnar in-memory format and interchange standard: typed columns, validity bitmaps, contiguous buffers, and language-neutral (Python, Rust, Java, C++). It is why
df.to_arrow()andduckdb.sql("select * from tbl")over an Arrow table are near-free. It also standardises IPC/Flight and Parquet reading. - Polars: Rust DataFrame library with an expression API, multi-threaded execution, a lazy query optimiser (predicate/projection pushdown, common subexpression elimination) and a streaming engine for larger-than-memory data.
- DuckDB: in-process analytical SQL database ("SQLite for analytics"): vectorised, parallel, reads Parquet/CSV/JSON directly (also over HTTP/S3), and can query Python objects (Arrow tables, Polars/pandas frames) by variable name.
Polars idioms¶
import polars as pl
lf = (
pl.scan_parquet("traces/*.parquet") # lazy: nothing read yet
.filter(pl.col("status") == "ok")
.with_columns(
(pl.col("prompt_tokens") + pl.col("completion_tokens")).alias("tokens"),
pl.col("started_at").dt.truncate("1h").alias("hour"),
)
.group_by("model", "hour")
.agg(
pl.len().alias("calls"),
pl.col("tokens").sum().alias("tokens"),
pl.col("latency_ms").quantile(0.99).alias("p99_ms"),
)
.sort("hour")
)
print(lf.explain()) # see pushdown of the filter and column projection into the scan
df = lf.collect() # or lf.collect(engine="streaming") for larger-than-RAM
Habits: expressions over apply/map_elements (Python UDFs run row-by-row on one thread and lose the optimiser), scan_* over read_*, select only needed columns, prefer categorical/enum dtypes for low-cardinality strings, avoid iterating rows (iter_rows), and use with_columns to batch expressions (executed in parallel).
DuckDB idioms¶
import duckdb
con = duckdb.connect() # in-memory. Use a file for persistence
con.sql("""
select model,
count(*) as calls,
quantile_cont(latency_ms, .99) as p99_ms
from 'traces/*.parquet'
where status = 'ok'
group by 1
order by calls desc
""").show()
tbl = df.to_arrow() # Polars to Arrow
top = con.sql("select model, sum(tokens) t from tbl group by 1").pl() # result back to Polars
DuckDB shines for SQL-first analysis, joins across files, window functions, ad-hoc exploration of Parquet on S3/Azure, and as an embedded engine in tests and services. It also has extensions (httpfs, azure, vss for vector similarity search, fts) and a persistent file format. Use con.execute(sql, params) with parameters, never f-string user input into SQL.
Choosing¶
| Need | Prefer |
|---|---|
| Fast DataFrame transforms in Python code, expression pipelines | Polars |
| SQL analysts, joins across many files, quick exploration | DuckDB |
| Existing ecosystem needing pandas objects (sklearn, plotting, legacy) | pandas (convert at the edge, to_pandas()) |
| Larger than RAM on one machine | Polars streaming or DuckDB (spills to disk) |
| Distributed (multi-node) | Spark/Ray/Dask/Trino, or a warehouse. Not these tools |
| OLTP, many small writes, concurrent writers | Postgres. DuckDB is single-writer analytics |
| Vector search at scale | pgvector/Qdrant (vector databases). DuckDB vss is fine for small local sets |
Parquet and layout¶
Columnar compression and row-group statistics let engines skip data. Partition by low-cardinality keys (date, tenant) but avoid tiny files (aim for 128 MB to 1 GB files, row groups ~100k-1M rows). Zstd compression (also in Python 3.14's stdlib compression.zstd) is a good default. Sort by frequent filter columns to improve min/max pruning.
Data tooling in LLM workflows¶
- Eval datasets: store cases as Parquet/JSONL with a schema (input, expected, tags, model outputs, judge scores). Polars
group_bygives per-slice pass-rates (evals). - Trace and cost analytics: export spans/usage to Parquet, then query with DuckDB for token spend by tenant/model/route.
- RAG ingestion: chunk metadata tables, dedup (
unique(subset=["hash"])), join with source metadata, and batch embed. Keep vectors in a vector DB, and metadata in columnar files as the system of record. - Validation at the boundary: validate a sample of rows through Pydantic (Pydantic) or use Polars-native checks (
pl.col(...).is_between,null_count) for full-table validation. Per-row Pydantic on 50M rows is the wrong tool. - Interop with async services: run heavy queries via
asyncio.to_thread(both release the GIL) with a bounded semaphore, and never in the event loop.
Senior nuance¶
- Eager vs lazy footguns: calling
.collect()early loses optimisation. Materialising a huge intermediate is the usual OOM. - Polars strings/categoricals and joins can silently produce cardinality explosions (many-to-many). Assert key uniqueness (
df.select(pl.col("id").is_unique().all())) before joins, and usevalidate="1:m"on joins. - Null semantics differ from pandas (NaN vs null) and
Nonein Python lists. Choose an explicit missing-value policy in schemas. - Timestamps and time zones are a perennial bug source: store UTC, keep tz-aware dtypes, and be explicit at ingestion.
- DuckDB concurrency: one process can hold the writer lock on a file. Multiple readers OK in read-only mode. In services, treat it as an embedded analytical component, not a shared server.
- Memory: Arrow buffers live outside the Python heap, so
tracemallocunder-reports. Usememrayor RSS (profiling). - Version churn: Polars still evolves quickly (deprecations between minors), so pin the version and read release notes on upgrade.
Resources¶
| Resource | Type | Why this one | Level | Cost |
|---|---|---|---|---|
| Polars user guide | docs | Expressions, lazy API and optimisation, streaming | intermediate | free |
| Polars: lazy API | docs | The optimiser and explain() |
intermediate | free |
| Polars: coming from pandas | docs | Concept mapping and gotchas | intermediate | free |
| DuckDB docs | docs | SQL dialect, extensions, Python client | intermediate | free |
| DuckDB Python client overview | docs | Relations, Arrow/Polars integration, parameters | intermediate | free |
| DuckDB + Polars guide | docs | Zero-copy interchange | intermediate | free |
| Apache Arrow Python docs | docs | Tables, compute, Parquet, dataset API | intermediate | free |
| Arrow columnar format spec |
spec | Why zero-copy works, buffers and validity bitmaps | advanced | free |
| Polars blog |
blog | Engine internals, benchmarks, streaming engine notes | advanced | free |
| Itamar Turner-Trauring: Python data processing articles |
article | Memory and performance for data pipelines | intermediate | free |
Hands-on lab¶
Goal (60 min): analyse synthetic LLM traces three ways and compare.
uv add polars duckdb pyarrow pandas(oruv run --with ...). Generate 2M synthetic trace rows (model, tenant, status, prompt/completion tokens, latency_ms, started_at) with NumPy or Polars expressions and write to Parquet (zstd). Note file size.- Query A (Polars lazy): p99 latency and tokens per (model, hour) for
status == "ok". Printlf.explain()and confirm the filter and projection are pushed into the parquet scan. - Query B (DuckDB SQL): same result directly from the Parquet file.
- Query C (pandas): same, using
read_parquetthen groupby. - Compare results (assert equal within tolerance), wall time and peak RSS for A/B/C. Expected: Polars and DuckDB are multi-core and typically several times faster than pandas on this shape, and use less memory. Record actual numbers on your machine.
- Zero-copy:
tbl = df.to_arrow(), thenduckdb.sql("select count(*) from tbl"), andpl.from_arrow(...). Verify no big time or RSS jump. - Inject a many-to-many join bug (duplicate keys in the dimension table). Observe row explosion, then add
validate="m:1"and see the error. - Stretch: wrap query A in
await asyncio.to_thread(...)inside a FastAPI endpoint with a semaphore, and confirm loop lag stays low under load.
Questions¶
L1 - Recall¶
Q1. What does Arrow provide that makes Polars/DuckDB interchange cheap?
Answer
A standard columnar in-memory layout (typed contiguous buffers plus validity bitmaps). Both engines can read and produce Arrow buffers directly, so handing a table across involves no serialisation or copying of column data.
Q2. Lazy vs eager in Polars: why prefer scan_parquet(...).collect()?
Answer
The lazy plan lets the optimiser push filters/projections into the scan (reading fewer row groups and columns), reorder operations, eliminate common subexpressions and choose streaming execution. Eager read_parquet loads everything first.
Q3. Why are map_elements/apply with Python lambdas discouraged in Polars?
Answer
They execute row-by-row in Python, single-threaded and GIL-bound, and are opaque to the optimiser. Express the logic with native expressions, or a vectorised approach.
Q4. What kinds of workloads is DuckDB not designed for?
Answer
High-concurrency OLTP with many small transactions and multiple writers, and distributed multi-node processing. It is an in-process analytical engine (one writer per database file).
L2 - Apply¶
Q5. Translate to Polars: SQL select carrier, count(*) n from t where delay > 24 group by carrier order by n desc.
Q6. A join of orders (1M rows) to customers unexpectedly returns 40M rows. Cause and prevention?
Answer
Duplicate keys in the right table cause a many-to-many join and row multiplication. Prevent with uniqueness checks before joining, validate="m:1" on the join (Polars raises if violated), unit tests on dimension uniqueness, and row-count assertions after joins in pipelines.
Q7. Your ETL job reads a 30 GB Parquet dataset with pl.read_parquet and is OOM-killed on a 16 GB box. Fix.
Answer
Switch to scan_parquet with column selection and filters pushed down, collect with the streaming engine (collect(engine="streaming")), or process by partition/row-group batches, or use DuckDB (out-of-core, spills to disk). Also cast wide string columns to categoricals, and drop unneeded columns early.
Q8. How do you run a 3-second DuckDB query from a FastAPI endpoint without hurting other requests?
Answer
Make it non-blocking to the loop: await asyncio.to_thread(run_query, params) (DuckDB releases the GIL), cap concurrency with a semaphore, set a statement timeout/interrupt (con.interrupt() on cancellation), use read-only connections per thread/cursor, parameterised SQL only, and cache results if repeated. For heavy or long jobs, submit to a worker queue rather than serving synchronously.
L3 - Design & trade-offs¶
Q9. Polars vs DuckDB vs pandas for the team's eval-analysis and trace-analytics tooling.
Answer
Polars: best for Python-centric pipelines with reusable, testable expression code and lazy optimisation. DuckDB: best where SQL fluency is broad, ad-hoc exploration, cross-file joins and using the same SQL in notebooks and services. pandas: keep for ecosystem interop (plotting, sklearn) at the edges. Standardise storage on Parquet + Arrow so tools are interchangeable, choose one primary API per pipeline (avoid mixing three in one file), and provide helpers for conversion. Decide by team skills and pipeline shape, not benchmarks alone.
Q10. Where should the system of record live for RAG chunk metadata and embeddings: Parquet, Postgres/pgvector, or a dedicated vector DB?
Answer
Keep durable, queryable metadata and lineage (source, hash, version, ACLs) in a transactional store or as versioned Parquet snapshots. Serve vector search from pgvector/Qdrant. Parquet/DuckDB is ideal for offline analytics, backfills and evals, and reproducible dataset versions. Avoid dual-write drift: make the pipeline derive the serving index from the system of record, idempotently, keyed by content hash.
Q11. Should you validate a 50M-row dataset with Pydantic models?
Answer
No. Per-row model construction is far too slow and memory heavy. Validate schema and invariants column-wise with Polars/DuckDB (dtype checks, null counts, ranges, uniqueness, referential checks) and use Pydantic on a sample or on the rules definitions. Fail the pipeline with a report and quarantine bad rows.
L4 - Staff-level ambiguity¶
Q12. Analysts want self-serve access to LLM trace analytics; engineering wants to avoid building a data platform. Propose an approach.
Answer
Emit traces/usage as OTel + batch export to Parquet in object storage (partitioned by date/tenant) with a documented schema and retention/PII policy. Provide DuckDB (local or MotherDuck-like hosted) plus a few maintained SQL views/notebooks for common questions (cost by tenant/model, p99 by route, failure taxonomy). Add access control at the storage layer and column-level redaction at export. Graduate to a warehouse/lakehouse only when concurrency, governance or scale demand it (data architecture). Measure: time-to-answer for analyst questions and count of ad-hoc data requests to engineering.
Q13. A team wants to replace their Spark cluster with Polars/DuckDB on a big box to save cost. Evaluate.
Answer
Profile actual data sizes and access patterns: if datasets fit on a large single node (hundreds of GB is often fine with streaming/out-of-core) and jobs are batch analytics, single-node engines can cut cost and complexity dramatically. Risks: no fault tolerance or horizontal scale, single-node ceiling, concurrency, workflow/orchestration gaps, skills and governance integration. Pilot on the 3 most expensive jobs with equivalence tests (row counts, aggregates), measure cost and runtime, keep Spark for genuinely distributed workloads, and define the ceiling that triggers re-evaluation.
Real-world use cases¶
- LLM cost dashboards: hourly Parquet exports and a DuckDB SQL view compute tokens and spend by tenant/model.
- Eval slicing: Polars group-bys over 100k eval runs to locate failure clusters by prompt category and model version.
- Document ingestion: dedup by content hash and metadata joins with Polars before batch embedding.
- Logistics analytics: ETA-error analysis over billions of vessel events using DuckDB on Parquet in object storage.
Pitfalls & anti-patterns¶
- Row-wise Python loops and
map_elementson big frames. - Early
.collect()and reading entire files to filter a few rows. - Joins without key uniqueness validation.
- f-string SQL with user input in DuckDB.
- Thousands of tiny Parquet files (small-file problem).
- Mixing naive and tz-aware timestamps.
- Running heavy queries on the event loop.
Checklist¶
- I can explain Arrow's role and lazy vs eager execution without notes
- I ran the same analysis in Polars, DuckDB and pandas and recorded time/RSS
- I demonstrated a zero-copy hop between Polars and DuckDB
- I answered all L3 questions out loud in < 3 min each