Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 40 minutes
  • Examples run with Python 3.14.8, pandas 3.0.6, polars 1.44.2 and pyarrow 25.0.1
  • By MySmartCoPilot
Free core, Pro depth: 13 of 40 minutes free
Free: A lazy plan defers result computation · Pushdown keeps four columns and Pune rows · Streaming reduces buffers while order needs a rule. With a pass: 3 in-depth parts and 3 interview answers, about 27 minutes.

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

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.

The same report, eager and lazy

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.

Interactive · stage by stage A lazy plan defers result computation Follow the original input through the stated transformation and check its output.
A lazy plan defers result computation, step 3 of 3. Showing Build query, Resolve schema, Collect report. In focus: Collect report.Build query100,000 orders; 10 colsResolve schemaString and Int64Collect report5 category totals

The drawing shows the whole scene; its 3 steps are listed below.

Every step as text
  1. The report filters Pune orders, computes quantity times price, and groups by category, using the same source as the eager version.
  2. Schema resolution identifies category and amount. It may inspect file metadata or inferred CSV values before collection.
  3. collect executes the result computation. The five sorted category totals agree with the eager report.

Checkpoint

Check: lazy query

  1. Question 1 of 2 What does lazy execution defer?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Materializing the query result

    A plan describes work whose result is executed by collect or a sink. Schema resolution can need source metadata, so lazy does not promise zero I/O before collect.

  2. Question 2 of 2 The report reads ten input columns but returns category and amount. How many output columns are there?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 2 columns

    The grouped report has category and its amount sum.

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.

A query plan before and after optimisation: the city filter moves into the CSV scan, which keeps 4 of the 10 columns.The plan as writtenThe optimised plan1. CSV scan: all 10 columns,every row2. with_columns: amount =qty × price, for every row3. filter: city == "Pune"4. group_by category,sum(amount), sort1. CSV scan: 4 of 10 columns(city, category, qty, price)SELECTION city == "Pune"applied while reading2. with_columns: amount,only for Pune's rows3. group_by category,sum(amount), sortthe optimiser rewrites the plan

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.

explain() before and after optimisation Python · read_the_plan.py
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.

Interactive · stage by stage Pushdown keeps four columns and Pune rows Follow the original input through the stated transformation and check its output.
Pushdown keeps four columns and Pune rows, step 3 of 3. Showing Written plan, Optimized scan, Aggregate. In focus: Aggregate.Written planPROJECT */10Optimized scanPROJECT 4/10Aggregatecategory and amount

The drawing shows the whole scene; its 3 steps are listed below.

Every step as text
  1. The unoptimized report loads ten columns, computes amount and then applies the city filter.
  2. The scan retains city, category, qty and price and applies the Pune predicate while reading.
  3. Only the required values continue into the grouped sum. Read the printed plan from its bottom scan upward.

Checkpoint

Check: pushdown plan

  1. Question 1 of 2 What does PROJECT 4/10 describe?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Columns retained by the scan

    Projection pushdown prunes input columns before later query stages. The report has two columns; scan width and output width are separate.

  2. Question 2 of 2 The scan keeps four of ten columns. How many are pruned?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 6 columns

    Keeping four of ten columns prunes the other six columns.

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.

Interactive · stage by stage Streaming reduces buffers while order needs a rule Follow the original input through the stated transformation and check its output.
Streaming reduces buffers while order needs a rule, step 3 of 3. Showing Choose engine, Process batches, Define order. In focus: Define order.Choose engineexplicit streamingProcess batchesstate still mattersDefine ordersort or maintain_order

The drawing shows the whole scene; its 3 steps are listed below.

Every step as text
  1. The recorded example chooses engine='streaming' rather than relying on a version's default.
  2. Batches can reduce input buffers, but grouping, sorting, joining, fallback operations and the final result can retain memory.
  3. Neither incidental group order nor a streaming row order is an output contract. Use an explicit rule when order matters.

Checkpoint

Check: engine order

  1. Question 1 of 2 Does requesting streaming guarantee constant memory for every query?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No; state, fallback and output can grow

    The physical operations and retained result determine memory requirements. Batch processing does not remove grouping or join state and cannot bound a collected result.

  2. Question 2 of 2 A query collects 100 rows in each of ten batches. How many rows are in the complete result?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 1000 rows

    10×100=1,000; collection still materializes the result.

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.

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.

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.

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.

  1. Question 1 of 9 What does lazy execution defer?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: Materializing the query result

    A plan describes work whose result is executed by collect or a sink. Schema resolution can need source metadata, so lazy does not promise zero I/O before collect.

  2. Question 2 of 9 Five categories each contribute one grouped row. How many report rows result?

    Type a number.

    Show the answer: Question 2 of 9

    Answer: 5 rows

    One row per category gives five.

  3. Question 3 of 9 What does PROJECT 4/10 describe?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: Columns retained by the scan

    Projection pushdown prunes input columns before later query stages. The report has two columns; scan width and output width are separate.

  4. Question 4 of 9 A report needs three of eight input columns. How many can projection prune?

    Type a number.

    Show the answer: Question 4 of 9

    Answer: 5 columns

    8−3=5, provided the rest of the plan does not use them.

  5. Question 5 of 9 Does requesting streaming guarantee constant memory for every query?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: No; state, fallback and output can grow

    The physical operations and retained result determine memory requirements. Batch processing does not remove grouping or join state and cannot bound a collected result.

  6. Question 6 of 9 Four output groups each need one sorted row. How many grouped rows are ordered?

    Type a number.

    Show the answer: Question 6 of 9

    Answer: 4 rows

    The explicit result ordering acts on four groups.

  7. Question 7 of 9 In the optimised plan, the scan shows PROJECT 4/10 COLUMNS and SELECTION: col("city") == "Pune". What does that tell you?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: The scan keeps 4 of the 10 columns and drops the other cities' orders while it reads the file

    PROJECT lists how many columns the scan keeps (projection pushdown) and SELECTION is a filter applied while reading (predicate pushdown). The result's columns are decided by the steps above the scan, here the category and the summed amount.

  8. Question 8 of 9 Which call reads the file and runs a lazy query?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: collect()

    scan_csv() only starts a plan, explain() prints the optimised plan and collect_schema() works out the result's columns and types from the plan. Only collect() (or a sink such as sink_parquet) optimises the plan and runs it.

  9. Question 9 of 9 A lazy pipeline that never sorts is moved from polars 1.44 to polars 2.0, and every collect() is left as it is. What can change in its output?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: The order of the rows that come out of group_by, joins and unpivot

    In polars 2.0 a plain collect() runs on the streaming engine, which does not keep row order for group_by, joins or unpivot. The values stay the same; the order they come in does not, unless the query sorts or passes maintain_order.

Interview questions

  1. 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.

  2. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

  4. 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?

    Model answer, Pro. First step and follow-ups

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 SELECTION inside the scan and the scan’s PROJECT line 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_parquet writes 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 pass maintain_order.
CSV / Excel to Parquet Converter Convert a CSV file to Parquet in your browser, so that lazy scans can read only the columns a query needs. Parquet, Avro & Arrow File Viewer Open a Parquet file to see its schema, row groups and column statistics, the facts that decide what a scan can skip.

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.