Data Analysis with Python (pandas, polars, DuckDB) Module 12 – polars: expressions, lazy queries and speed
Lazy queries, query plans and streaming in polars
Build lazy queries in polars, read the optimised plan with explain(), see predicate and projection pushdown at work, and stream large files in batches.
What you will learn
- Build LazyFrames and read their optimised plans with explain
- Explain predicate and projection pushdown and why lazy queries read less data
- Run large queries with the streaming engine and write results with sink_parquet
- Write order-safe pipelines that return the same rows on polars 1.x and 2.0
Before you start
What you’ll learn · Before you start
What you will learn
- Build LazyFrames and read their optimised plans with explain
- Explain predicate and projection pushdown and why lazy queries read less data
- Run large queries with the streaming engine and write results with sink_parquet
- Write order-safe pipelines that return the same rows on polars 1.x and 2.0
Before you start
On this page
For a 100,000-order report, a lazy query plan: A description of a query whose result is computed by collection or a sink; resolving its schema can still inspect source metadata. in polars describes a whole computation first and runs it only when you ask for the result. You start one
with pl.scan_csv or pl.scan_parquet instead of read_csv, or with .lazy() on a DataFrame you already have, and
chain the same methods as in eager code, but each method adds a step to a query plan instead of running. collect()
hands the plan to an optimiser before the result is materialized; schema resolution may already inspect source metadata: it applies filters while the file is read
(predicate pushdown: Moving a filter closer to a query's data source when the transformation preserves the intended result.) and reads only the columns the query uses (projection pushdown: Retaining only the input columns a query needs so that later stages handle fewer values.), then runs the result.
explain() prints the optimised plan, so you can see what will be read before you read it. streaming engine: Query execution that can process data in batches; operator state, fallback operations and collected output can still require growing memory. in polars runs
the same plan in batches, which lets a query work through data that does not fit in memory.
A lazy plan defers result computation
Lazy queries pay off most when the data is large or the query is long, and they cost almost nothing to adopt: the methods are the ones you already know from the previous lesson.
Here is one report written both ways: revenue per category for the city of Pune, from 100,000 generated orders in a
CSV file of 10 columns. make_sales.py makes the same rows every time it runs.
eager_and_lazy.py
import polars as pl
from make_sales import sales
sales(100_000).write_csv("sales.csv") # 100,000 orders, 10 columns
# Eager: every line runs as soon as it is reached and returns a DataFrame.
df = pl.read_csv("sales.csv")
df = df.with_columns(amount=pl.col("qty") * pl.col("price"))
df = df.filter(pl.col("city") == "Pune")
eager = df.group_by("category").agg(pl.col("amount").sum()).sort("category")
# Lazy: the steps build a plan; schema resolution may inspect the CSV before result collection.
query = (
pl.scan_csv("sales.csv")
.with_columns(amount=pl.col("qty") * pl.col("price"))
.filter(pl.col("city") == "Pune")
.group_by("category")
.agg(pl.col("amount").sum())
.sort("category")
)
print(type(query).__name__)
print(query.collect_schema()) # resolve columns and types; this may read source metadata
lazy = query.collect() # optimise the plan, then run it
print(lazy)
print("same result as eager:", lazy.equals(eager)) Output
LazyFrame
Schema({'category': String, 'amount': Int64})
shape: (5, 2)
┌───────────┬─────────┐
│ category ┆ amount │
│ --- ┆ --- │
│ str ┆ i64 │
╞═══════════╪═════════╡
│ bakery ┆ 2547784 │
│ beverages ┆ 2561638 │
│ dairy ┆ 2574859 │
│ grocery ┆ 2618408 │
│ snacks ┆ 2528771 │
└───────────┴─────────┘
same result as eager: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 eager_and_lazy.py
make_sales.py
"""A synthetic sales table for the lazy-query examples: n orders in 10 columns, always the same rows for the same n."""
import numpy as np
import polars as pl
CITIES = pl.Series(["Pune", "Nagpur", "Nashik", "Thane", "Solapur", "Kolhapur"])
CATEGORIES = pl.Series(["grocery", "snacks", "dairy", "bakery", "beverages"])
CHANNELS = pl.Series(["app", "web", "store"])
CUSTOMERS = pl.Series([f"Customer {i:05d}" for i in range(5000)])
ADDRESSES = pl.Series([f"Flat {i % 90 + 1}, Lane {i % 37 + 1}, Sector {i % 29 + 1}" for i in range(20000)])
def sales(n: int, seed: int = 2026) -> pl.DataFrame:
rng = np.random.default_rng(seed)
return pl.DataFrame({
"order_id": np.arange(1, n + 1),
"city": CITIES.gather(rng.integers(0, len(CITIES), n)),
"category": CATEGORIES.gather(rng.integers(0, len(CATEGORIES), n)),
"qty": rng.integers(1, 6, n),
"price": rng.integers(10, 500, n),
"discount": rng.integers(0, 30, n),
"channel": CHANNELS.gather(rng.integers(0, len(CHANNELS), n)),
"pin": rng.integers(411001, 411062, n),
"customer": CUSTOMERS.gather(rng.integers(0, len(CUSTOMERS), n)),
"address": ADDRESSES.gather(rng.integers(0, len(ADDRESSES), n)),
}) The lazy code builds a LazyFrame rather than computing the report immediately. collect_schema() resolves the
result’s String and Int64 columns, potentially reading file metadata or CSV values for inference. It does not
materialize the complete result, but it is not a promise of zero I/O or negligible cost. Use it to check the schema before a long run. Both versions give the same five totals.
Follow the input and compare the result with the recorded example.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The report filters Pune orders, computes quantity times price, and groups by category, using the same source as the eager version.
- Schema resolution identifies category and amount. It may inspect file metadata or inferred CSV values before collection.
- collect executes the result computation. The five sorted category totals agree with the eager report.
Checkpoint
Check: lazy query
Pushdown keeps four columns and Pune rows
polars keeps two query plans for every lazy query: the one you wrote and the one the optimiser made from it.
Predicate and projection pushdown in one plan
Text description of the diagram
The query reads sales.csv, computes amount as qty times price, keeps the orders from Pune, and sums the amount per category.
As written, which explain(optimized=False) prints, the plan has four steps in this order: the CSV scan reads all 10 columns of every row; with_columns computes amount for every row; the filter keeps the rows where city is "Pune"; group_by sums amount per category and sorts the result.
In the optimised plan, which explain() prints, the filter has moved into the scan, where it is applied while the file is read (predicate pushdown), and the scan keeps only the 4 columns the query uses, city, category, qty and price (projection pushdown). The amount is then computed only for the rows from Pune, and the aggregation and the sort are unchanged.
explain() prints the same steps the other way up, with the last step first and the scan at the bottom.
import polars as pl
from make_sales import sales
sales(1_000).write_csv("sales.csv")
query = (
pl.scan_csv("sales.csv")
.with_columns(amount=pl.col("qty") * pl.col("price"))
.filter(pl.col("city") == "Pune")
.group_by("category")
.agg(pl.col("amount").sum())
.sort("category")
)
# Plans print from the last step at the top down to the scan at the bottom: read them bottom up.
print("As written:")
print(query.explain(optimized=False))
print()
print("Optimised:")
print(query.explain()) Output
As written:
SORT BY [col("category")]
AGGREGATE[maintain_order: false]
[col("amount").sum()] BY [col("category")]
FROM
FILTER col("city") == "Pune"
FROM
WITH_COLUMNS:
[(col("qty") * col("price")).alias("amount")]
Csv SCAN [sales.csv]
PROJECT */10 COLUMNS
ESTIMATED ROWS: 1000
Optimised:
SORT BY [col("category")]
AGGREGATE[maintain_order: false]
[col("amount").sum()] BY [col("category")]
FROM
simple π 2/2 ["category", "amount"]
WITH_COLUMNS:
[(col("qty") * col("price")).alias("amount")]
simple π 3/3 ["category", "qty", "price"]
Csv SCAN [sales.csv]
PROJECT 4/10 COLUMNS
SELECTION: col("city") == "Pune"
ESTIMATED ROWS: 1000
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 read_the_plan.py
Query plans print with the last step at the top and the scan at the bottom, so read them from the bottom up. As
written, the scan reads all 10 columns (PROJECT */10 COLUMNS) of every row, and the filter runs only after amount has been
computed for all of them. In the optimised plan the condition has moved into the scan as SELECTION, so orders from
other cities are dropped while the file is read, and the scan keeps 4 of the 10 columns (PROJECT 4/10 COLUMNS), the
ones the query uses. The lines that start with π are projections: after the scan, only the columns still needed move
on.
The optimiser has more rewrites than these two. Slice pushdown reads only the rows a head() needs. Common
subplan elimination reads a file once when the plan uses it twice. Expression simplification replaces costly
expressions with cheaper ones that give the same values. Each rewrite is allowed only when the result stays the same,
and the plan shows which ones happened.
Follow the input and compare the result with the recorded example.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The unoptimized report loads ten columns, computes amount and then applies the city filter.
- The scan retains city, category, qty and price and applies the Pune predicate while reading.
- Only the required values continue into the grouped sum. Read the printed plan from its bottom scan upward.
Checkpoint
Check: pushdown plan
Streaming reduces buffers while order needs a rule
Streaming in polars means running a lazy query in batches: collect(engine="streaming"). Batched execution can reduce input buffering and handle files larger than memory. Aggregation, joins, sorts,
fallback operations and collected output can still retain growing state. sink_parquet or sink_csv writes output
in batches without returning the whole result as one DataFrame; it does not remove every operator’s memory needs. In polars 1.x a
plain collect() uses the in-memory engine, which processes each step’s whole input at once. In polars 2.0, lazy
queries run on the streaming engine by default.
That default has a cost. The streaming engine does not keep row order for group_by, joins or unpivot, and the
order of groups is not promised by either engine, so a pipeline that depends on the order rows happen to arrive in can
change its output after an upgrade. An explicit sort, or maintain_order where an operation offers it, makes the
order part of the query. LazyFrame.profile(), which timed each step, is deprecated in 1.43 and removed in 2.0: time
a whole query with time.perf_counter() instead.
| Option | Details |
|---|---|
| Starts with | Eager: read_csv, read_parquet; Lazy, in-memory engine: scan_csv, scan_parquet or .lazy(); Lazy, streaming engine: The same scans |
| Runs | Eager: Each line, as it is reached; Lazy, in-memory engine: The optimised plan, all at once, on collect(); Lazy, streaming engine: The optimised plan, batch by batch |
| Reads | Eager: Every row and column you load; Lazy, in-memory engine: Only the rows and columns the plan needs; Lazy, streaming engine: The same as in-memory |
| Default in | Eager: All versions; Lazy, in-memory engine: polars 1.x, for collect(); Lazy, streaming engine: polars 2.0, for collect() |
Follow the input and compare the result with the recorded example.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The recorded example chooses engine='streaming' rather than relying on a version's default.
- Batches can reduce input buffers, but grouping, sorting, joining, fallback operations and the final result can retain memory.
- Neither incidental group order nor a streaming row order is an output contract. Use an explicit rule when order matters.
Checkpoint
Check: engine order
Eager, lazy and streaming, measured on two million orders Pro: opens with a Pro, Premium or Ultimate pass
One report on two million generated orders, run eager, lazy and streaming, from a CSV file and from a Parquet file, each run in a fresh process. The recorded output gives the times and the peak memory of every run, and the text works out which numbers move and why.
In this part:
- Fresh processes separate peak memory measurements
- A sink writes the selected result in batches
2 diagrams · 2 runnable examples (Python) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Pipelines that return the same rows on every engine and version Pro: opens with a Pro, Premium or Ultimate pass
The streaming engine, the default for lazy queries in polars 2.0, does not keep row order for group_by, joins or
unpivot. This part reproduces the reordering on polars 1.44.2 by choosing the engine, then builds a pipeline that
returns identical results on both engines and for any input order.
In this part:
- Equal rows can arrive in different orders
- Total sort keys make output reproducible
- A lazy report keeps five columns and breaks ties
3 diagrams · 4 runnable examples (Python) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
What the optimiser can and cannot move, read in six plans Pro: opens with a Pro, Premium or Ultimate pass
The optimiser moves a step only when the result stays the same, and that rule decides every plan. This part reads six
plans, from a head() that reads five rows to a filter that has to wait for a window function, measures what a Python
function in a filter costs, and turns a plan into a test.
In this part:
- A filter moves only when results are preserved
- A pushed Python predicate still executes costly calls
- Plan checks detect lost scan filtering
3 diagrams · 3 runnable examples (Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise the stated transformations
Practice that adapts to you
Practise this lesson
9 questions about 11 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
-
Fresher to mid · Warm-up
What does collect() do to a polars LazyFrame, and what does explain() show?
A LazyFrame is a query plan: scan_csv, scan_parquet or .lazy() starts one, and every method you chain adds a step without materializing the result. Source metadata or schema inference can still require reads. collect() hands the plan to the optimiser, which rewrites it, for example by moving filters into the scan and dropping columns the query never uses, and then runs it and returns a DataFrame. explain() prints the optimised plan as text, with the last step at the top and the scan at the bottom, without running anything. Read the scan's lines first: SELECTION shows a filter applied while reading, and PROJECT shows how many columns are read. explain(optimized=False) prints the plan as written, which makes the optimiser's changes easy to see.
-
Mid to senior
A nightly polars job reads a 30 GB CSV file with read_csv, keeps one state's rows and sums sales by district. It is killed for using too much memory on a 16 GB machine. How do you rewrite it, and how do you show that the new version reads less?
-
Mid to senior
After an upgrade from polars 1.x to 2.0, a daily report has the same totals but its rows come out in a different order, and a downstream job that compares reports line by line flags thousands of changes. Explain what happened and fix it so that it cannot happen again.
-
Senior to staff
explain() shows your filter at the top of the plan instead of as a SELECTION inside the scan, and the job reads far more data than it should. What can keep a predicate from being pushed down, and how do you restructure the query?
Key takeaways
scan_*or.lazy()builds a plan;collect()optimises and runs it;explain()prints the optimised plan, which reads from the bottom up.- In a good plan, the filter appears as
SELECTIONinside the scan and the scan’sPROJECTline names only the columns the query uses. - The streaming engine runs a plan in batches and is the default for lazy queries in polars 2.0;
sink_parquetwrites a result without collecting it. - Neither engine promises group order, and the streaming engine does not keep row order for joins or
unpivot: sort explicitly or passmaintain_order.
Terms in this lesson
- lazy query plan
- A description of a query whose result is computed by collection or a sink; resolving its schema can still inspect source metadata.
- predicate pushdown
- Moving a filter closer to a query's data source when the transformation preserves the intended result.
- projection pushdown
- Retaining only the input columns a query needs so that later stages handle fewer values.
- streaming engine
- Query execution that can process data in batches; operator state, fallback operations and collected output can still require growing memory.
References
- Lazy API (Polars user guide) (Polars)
- Usage of the lazy API (Polars user guide) (Polars)
- Optimizations (Polars user guide) (Polars)
- Query plan (Polars user guide) (Polars)
- Streaming (Polars user guide) (Polars)
- Sources and sinks (Polars user guide) (Polars)
- polars.LazyFrame.collect (Python API reference) (Polars)
- polars.DataFrame.join (Python API reference) (Polars)
- polars.Expr.map_elements (Python API reference) (Polars)
- Upgrading to version 2.0 (Polars user guide) (Polars)
- resource, resource usage information (Python documentation) (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress