Polars Lazy API Cuts ETL Wall Time by 80 Percent

Data Science
Date:November 13, 2026
Topic:
Polars Lazy API Cuts ETL Wall Time by 80 Percent
⏱ 2 min read

Your ETL pipeline runs for six hours. The CPU sits at 12% while memory spikes to 90%. You've indexed, partitioned, and tuned garbage collection. Nothing moves the needle.

The Problem Isn't Your Hardware

Most DataFrame libraries execute eagerly. Every filter, join, and groupby materializes intermediate results. With 50GB datasets, you're not computing—you're copying memory. Polars' Lazy API flips this model. You describe the entire transformation as a query plan. The optimizer sees the full DAG before a single row moves.

python
import polars as pl

# Eager: materializes at each step
df = pl.read_parquet("s3://bucket/events/*.parquet")
filtered = df.filter(pl.col("event_type") == "purchase")
joined = filtered.join(users, on="user_id")
agg = joined.group_by("country").agg(pl.col("amount").sum())
result = agg.collect()  # executes here

# Lazy: single optimized plan
lazy = (
    pl.scan_parquet("s3://bucket/events/*.parquet")
    .filter(pl.col("event_type") == "purchase")
    .join(users.lazy(), on="user_id")
    .group_by("country")
    .agg(pl.col("amount").sum())
    .collect()  # executes entire plan once
)

What the Optimizer Actually Does

Predicate pushdown moves filters to the scan level—Parquet row groups get skipped entirely. Projection pushdown reads only required columns. Join reordering picks the optimal build/probe side based on statistics. Partition pruning eliminates whole files. These aren't theoretical; they compound multiplicatively.

OptimizationTypical Speedup
Predicate pushdown3-10x
Projection pushdown2-5x
Join reordering2-20x
Partition pruning5-100x

Real-World Pattern: Incremental Aggregation

Daily rollups over three years of event data. The naive approach reads everything, groups, writes. The lazy approach:

python
# Scan partitioned by date
events = pl.scan_parquet("s3://events/year=*/month=*/day=*/")

# Only touch last 30 days
events = events.filter(pl.col("date") >= pl.date.today() - pl.duration(days=30))

# Push aggregation to scan
daily = events.group_by("date", "event_type").agg(
    pl.col("user_id").n_unique().alias("dau"),
    pl.col("revenue").sum()
)

# Materialize once
result = daily.collect(streaming=True)
💡
TipUse .collect(streaming=True) for out-of-core execution. Polars processes chunks sequentially, keeping memory flat regardless of dataset size.

Debugging the Plan

Before collecting, inspect the optimized plan:

python
print(lazy.explain())
# Shows: SCAN PARQUET [predicate, projection]
#        FILTER
#        HASH JOIN [reordered]
#        GROUP BY
#        SINK

Look for SCAN nodes with pushed predicates and projections. Missing pushdowns usually mean the optimizer lacks statistics—run ANALYZE on your Parquet files or provide schema hints.

Common Pitfalls

Calling .collect() inside a loop defeats lazy evaluation. Each call executes the plan up to that point. Build the full expression chain first. Another trap: mixing eager and lazy objects. Join a lazy frame with an eager DataFrame and Polars must materialize the eager side, losing pushdown opportunities.

"

The fastest code is code that never runs. Lazy evaluation lets you write the intent; the engine writes the execution.

— Ritchie Vink, Polars Creator

Your Next Step

Take your slowest ETL job. Wrap the read in scan_parquet, chain every transformation, add .collect(streaming=True) at the end. Measure wall time. Expect 50-80% reduction on I/O-bound workloads, 30-60% on compute-bound. The plan explain() output becomes your new profiling tool—read it like a query plan, because it is one.


✦
Share𝕏 Twitterin LinkedInin Whatsapp