Week 2 · Clean and trust the data › Day 9
Day 9. Transforming with Polars and DuckDB
By the end of today you can
- Write lazy Polars pipelines with expressions
- Query CSV and Parquet directly with DuckDB SQL
- Write partitioned Parquet and explain why columnar formats win
- Choose pandas, Polars or DuckDB for a given job
1. Why this matters
pandas taught a generation to analyse data, but for pipelines it's slow and memory-hungry. Polars (Rust, multi-threaded, lazy) and DuckDB (an in-process analytical SQL database) are now the standard open-source tools for single-machine ETL. With them, a laptop handles hundreds of millions of rows, and medium-sized companies often don't need Spark at all.
2. Polars: expressions and laziness
import polars as pl
lf = (
pl.scan_csv("data/landing/shared_drive/qsr/*/pos_*.csv", include_file_paths="src_file",
schema_overrides={"order_ts": pl.Utf8}, encoding="utf8-lossy")
.with_columns(
pl.col("order_ts").str.strptime(pl.Datetime, "%Y-%m-%d %H:%M:%S", strict=False),
(pl.col("qty") * pl.col("price")).alias("line_total"),
)
.filter(pl.col("order_ts").is_not_null())
)
daily = (lf.group_by(pl.col("restaurant_id"), pl.col("order_ts").dt.date().alias("cal_date"))
.agg(pl.len().alias("lines"), pl.col("line_total").sum().alias("revenue")))
print(daily.explain()) # the optimised plan: predicate and projection pushdown
df = daily.collect()
Lazy means Polars builds a plan and optimises it before running: it reads only the columns you use, pushes filters into the scan, and runs in parallel.
3. DuckDB: SQL on files
import duckdb
con = duckdb.connect("data/warehouse.duckdb")
con.sql("""
create or replace table stg_pos as
select * replace (try_strptime(order_ts, '%Y-%m-%d %H:%M:%S') as order_ts)
from read_csv('data/landing/shared_drive/qsr/*/pos_*.csv', union_by_name=true, filename=true, all_varchar=true)
""")
con.sql("copy stg_pos to 'data/staging/qsr_pos' (format parquet, partition_by (restaurant_id), overwrite_or_ignore)")
DuckDB and Polars share data with zero copy through Apache Arrow: con.sql("…").pl() returns a Polars frame; con.sql("select * from df") queries a Polars frame in place.
4. Why Parquet
Parquet stores data by column, compressed, with min/max statistics per row group. A query for sum(revenue) where cal_date = '2026-09-14' reads one column and skips row groups whose date range doesn't include that day.
Maths and principle
Principle: columnar storage reduces I/O in proportion to the share of columns and rows a query needs.
Equation: bytes read \(\approx N \cdot \sum_{c \in Q} w_c \cdot s\), where \(Q\) = columns used, \(w_c\) = compressed width, \(s\) = share of row groups not skipped. A row store reads \(N \cdot \sum_{\text{all } c} w_c\).
Analogy: reading one column of a spreadsheet printout vs photocopying every page. Where it breaks: select * on a few rows (point lookups) favours row stores; that's why OLTP databases are row stores.
Try it: 20 columns of equal width; query uses 3; date filter keeps 1/30 of row groups. Fraction of bytes read vs CSV? (3/20 × 1/30 = 0.5%, before compression gains.)
5. Which tool when
| Job | Pick | Why |
|---|---|---|
| Reading messy files, row-wise Python logic | Polars (or pandas for odd formats) | Expressions + UDF escape hatch |
| Set logic, joins, aggregation, window functions | DuckDB SQL | SQL is readable and portable to the warehouse later |
| Exploring in a notebook with plotting libraries | pandas / Polars | Ecosystem |
| Data bigger than memory on one machine | DuckDB or Polars streaming | Out-of-core execution |
| Many TB, many machines | Spark / Databricks, or the warehouse itself | Then it's worth the overhead |
The 5-year lens
A useful rule: write business logic in SQL, plumbing in Python. SQL logic moves to Azure SQL or dbt in Weeks 4–5 nearly unchanged; Polars logic has to be rewritten. Keep Polars for parsing, typing and shaping raw files, and push joins and metrics to SQL. Also: pin your Polars version. It moves fast and renames things between minor versions.
Lab
- Load all QSR POS files with pandas, Polars (lazy) and DuckDB. Time each with
time.perf_counter()and record peak memory (/usr/bin/time -von Linux, Task Manager on Windows). Put the table in the README. - Build
stg_qsr_posin Polars: parse timestamps (bad ones → null), addline_total,src_file,loaded_at. - Write it to Parquet partitioned by
business_date(use calendar date for now; Day 10 fixes the business day). - In DuckDB, query the Parquet: revenue by brand and channel for one week. Check DuckDB's
explain analyzeshows partition pruning. - Re-run with 120 days of generated data (
--days 120) and repeat the timing. Which tool scaled best on your machine?
Ship
feat(clean): staging POS in Polars, Parquet partitions, benchmark table.
Check yourself
1. What does "lazy" mean in Polars?
Laziness enables predicate/projection pushdown and parallel execution.
2. Why keep metric logic in SQL rather than Polars when you'll load into a warehouse later?
Portability of business logic is a design goal.
3. A query needs 2 of 40 columns. Which format reads the least data?
Columnar storage reads only the two columns' pages.