Query plans and optimizations

Polars · Advanced · 15 min

Knowing what the optimizer does lets you write queries it can speed up and spot why one is slow.

lf.explain() prints the optimized plan as text; lf.explain(optimized=False) shows your query as written. Read the plan bottom-up: the scan is at the bottom, the final operation at the top. lf.show_graph() draws it (needs graphviz).

The main optimizations:

  • Projection pushdown: only columns the query uses are read. In the plan the scan shows something like PROJECT 2/5 COLUMNS.
  • Predicate pushdown: filters move as early as possible, ideally into the scan (SELECTION: on the scan node), where Parquet statistics can skip whole row groups.
  • Slice pushdown: head(n) stops reading early.
  • Simplification and type coercion: constant folding, cheaper equivalent expressions.
  • Common subplan / subexpression elimination: a sub-query or expression used twice is computed once.

Some things block optimizations. A Python UDF (map_elements, map_batches) is opaque, so filters can't move past it. Collecting in the middle of a pipeline cuts the plan in two. Reading CSV can't skip row groups the way Parquet can.

When two outputs share work, collect them together with pl.collect_all([q1, q2]) so the shared part runs once. Turning optimizations off (collect(optimizations=...)) is useful only when debugging a suspected optimizer bug.

Example

import polars as pl

pl.DataFrame({"a": range(6), "b": list("xyxyxy"), "c": [1.0] * 6,
              "d": [0] * 6}).write_parquet("t.parquet")

q = (
    pl.scan_parquet("t.parquet")
    .with_columns(a2=pl.col("a") * 2)
    .filter(pl.col("b") == "x")
    .select("a2", "b")
)
plan = q.explain()
print(plan)
# The filter on b and the column list are pushed into the Parquet scan;
# look for the scan node showing PROJECT 2/4 COLUMNS and a SELECTION.
print(q.collect()["a2"].to_list())   # [0, 4, 8]

Remember

  • explain() shows the optimized plan; read it bottom-up
  • Projection and predicate pushdown move work into the scan
  • Python UDFs and early collects block optimizations

Watch out for

  • Putting a map_elements early in the pipeline and wondering why filters are not pushed down.

Check yourself

  1. A query reads a 50-column Parquet file but only uses 3 columns. Which optimization keeps the other 47 unread?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.pola.rs/user-guide/lazy/optimizations/