Pipeline to Dashboardnihalsulaiman.com

Week 2 · Clean and trust the data › Day 9

Day 9. Transforming with Polars and DuckDB

⏱ 4h · learn 60m · lab 140m · ship 25m · review 15m🏷 QSRBefore: Day 8

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

  1. Load all QSR POS files with pandas, Polars (lazy) and DuckDB. Time each with time.perf_counter() and record peak memory (/usr/bin/time -v on Linux, Task Manager on Windows). Put the table in the README.
  2. Build stg_qsr_pos in Polars: parse timestamps (bad ones → null), add line_total, src_file, loaded_at.
  3. Write it to Parquet partitioned by business_date (use calendar date for now; Day 10 fixes the business day).
  4. In DuckDB, query the Parquet: revenue by brand and channel for one week. Check DuckDB's explain analyze shows partition pruning.
  5. 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?

2. Why keep metric logic in SQL rather than Polars when you'll load into a warehouse later?

3. A query needs 2 of 40 columns. Which format reads the least data?

My notes