Columnar Thinking with polars
P12.data-quality-sql.03 · Audience: guest, it-ml, language-pro · Prerequisites: SQL for Analytics I — Select, Filter, Group
SQL describes what rows you want; polars is a dataframe library that lets you describe the same transformations in Python — and it is built on a different physical idea than the row-by-row loops most people picture. Data lives in columns, work is described as expressions, and — the part worth travelling for — nothing runs until you ask for the answer. That last property, lazy evaluation, lets polars see your whole query before executing a line of it, and optimise the plan the way a SQL engine does. This module is about the shape of thinking columnar tools reward; it does not re-teach idiomatic pandas, which lives in Python Craft (P4) — the two are complementary lenses on the same tables.
Picture our orders table. You read it as rows — one order per line, all its fields together — because that is how a receipt or a spreadsheet reads. But an analytical engine usually stores it as columns: every order_id contiguous in memory, then every quantity, then every product_id. Same table, transposed in how the bytes are laid out.
ⓘ Concept: Row-oriented vs column-oriented storage
Why it matters — The layout decides what is cheap. Row storage keeps a whole record together, so fetching or writing one entire row is fast — the right choice for a transactional system inserting orders one at a time. Column storage keeps each field's values together, so scanning or aggregating one column reads a tight, contiguous block and skips every column you did not ask for — the right choice for analytics, which typically touches a few columns across millions of rows. Most 'why is this so slow?' surprises trace back to using a layout against its grain.
This is why the same SUM(quantity) that an order-entry database labours over, a columnar tool answers in a blink: it reads one contiguous run of numbers and ignores everything else. Analytics is overwhelmingly "a few columns, many rows," and columnar storage is the layout built for exactly that shape.
Going deeper (technical) — reading the query plan
Interview-grade notes for the technical (it-ml) track.
The plan is a real object you can inspect. A LazyFrame carries a logical
plan; plan.explain() prints it, and plan.show_graph() renders it. Read it
bottom-up (the scan is at the leaves, the sink at the root). Two plans matter:
the logical plan (what you wrote) and the optimised physical plan (what will
actually run). The gap between them is the optimiser's work, and reading both
side by side is the fastest way to learn what polars rewrote.
Predicate pushdown is the headline optimisation. A .filter(pl.col("city") == "Paris") written after a scan is pushed into the scan, so rows failing
the predicate are dropped as the file is read — never decoded, never allocated.
On a Parquet file this compounds with the format's own row-group statistics:
entire row groups whose min/max range excludes "Paris" are skipped without being
read at all. In the explain() output you will see the predicate migrate down
to the SCAN node — that migration is the pushdown.
Projection pushdown is its sibling: only the columns the plan actually
references are read from disk. Select two columns out of fifty and the other
forty-eight are never touched — the single biggest reason columnar formats fly
on wide tables. Again, explain() shows the projected column set attached to the
scan node.
Lazy vs eager, precisely. Eager (pl.DataFrame, .read_*) executes each
call immediately and returns data; lazy (pl.LazyFrame, .scan_*) returns a
plan and defers until .collect(). Prefer scan_parquet/scan_csv over
read_* for large inputs precisely so pushdown can prevent the read; use eager
for small interactive work where the optimiser has nothing to gain and you want
the value now. The classic anti-pattern is .collect() in the middle of a
pipeline — it materialises early and severs the plan, forfeiting every
cross-step optimisation after that point. Collect once, at the end.
Streaming. collect(streaming=True) runs the plan in batches rather than
loading the whole frame, letting a query process data larger than memory — the
lazy plan is what makes this possible, because the engine already knows the full
pipeline and can push batches through it end to end. Not every operation has a
streaming implementation, so check the plan for a full-materialisation node if a
larger-than-memory job unexpectedly blows up.
Ask the mentor about this module
Ask a question about this content. The mentor explains and grounds its answer in what you are studying; asking is recorded as a learning signal, not a grade.
Try it yourself
A scratch console for this page's ideas — ungraded, nothing you run here is recorded.
Scratch console
A scratch console with the scientific stack (pandas, numpy, scikit-learn). Runs on the server — no network, resource-limited and measured.
Output appears here.
Where next?
Later in Data Quality & SQL
Go up a level