The lazy API: scan and collect

Polars · Practical · 15 min

Lazy mode is how Polars gets its biggest speed and memory wins, and it is the recommended way to write pipelines.

In eager mode every method runs immediately. In lazy mode methods only build a query plan, and nothing happens until you call .collect(). Get a LazyFrame from pl.scan_parquet(...), pl.scan_csv(...), pl.scan_ndjson(...) or df.lazy().

Because Polars sees the whole query before running it, it can optimize: read only the columns and row groups the query needs, push filters down into the file reader, drop unused computations, and run steps in parallel. Code reads the same as eager code; the only difference is where the query starts and the collect() at the end.

q = (pl.scan_parquet("events/*.parquet")
       .filter(pl.col("country") == "NO")
       .group_by("day").agg(pl.len()))
df = q.collect()

In Polars 2, collect() uses the streaming engine by default: it processes data in batches across all cores, so it can handle inputs bigger than RAM. A consequence is that row order after joins, group-bys and unpivots is not guaranteed; sort explicitly when it matters. collect(engine="in-memory") selects the older engine if you need it.

lf.collect_schema() returns the result schema without running anything, and lf.head(5).collect() is the quick way to peek. Mixing is fine: collect, do something eager, then .lazy() again.

Example

import polars as pl

pl.DataFrame({
    "day": ["mon", "mon", "tue", "tue", "tue"],
    "country": ["NO", "SE", "NO", "NO", "SE"],
    "ms": [120, 80, 95, 130, 70],
}).write_parquet("events.parquet")

q = (
    pl.scan_parquet("events.parquet")          # nothing is read yet
    .filter(pl.col("country") == "NO")
    .group_by("day")
    .agg(n=pl.len(), avg_ms=pl.col("ms").mean())
    .sort("day")
)
print(type(q).__name__)          # LazyFrame
print(q.collect_schema())        # Schema({'day': String, 'n': UInt32, 'avg_ms': Float64})
print(q.collect().rows())        # [('mon', 1, 120.0), ('tue', 2, 112.5)]

Remember

  • LazyFrames build a plan; collect() executes it
  • Start from scan_* so the optimizer can prune columns and rows at the source
  • Polars 2 collects with the streaming engine by default; sort if order matters

Watch out for

  • Calling collect() after every step, which throws away most of the optimization.

Check yourself

  1. Why does pl.scan_parquet(...).select('a').collect() usually read less data than pl.read_parquet(...).select('a')?

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

Official docs: docs.pola.rs/user-guide/concepts/lazy-api/