Lazy API and Performance - Python
Quote
“Premature optimization is the root of all evil.”
— Donald Knuth, Structured Programming with go to Statements (1974)
“The First Rule of Program Optimization: Don’t do it. The Second Rule of Program Optimization (for experts only): Don’t do it yet.”
— Michael A. Jackson, Principles of Program Design (1975)
Summary
Contrasts immediate Pandas-style execution with Polars’ deferred LazyFrame model, showing how query planning, pushdown, streaming, and profiling change the cost of disk reads and multi-step pipelines, and how to benchmark those differences without drawing false conclusions from toy workloads.
Eager: Immediate / Lazy: Deferred
- Compare eager materialization (
pd.read_parquet(),pl.read_parquet()) with lazy scanning (pl.scan_parquet(),pl.scan_csv()) and explain when each execution model is appropriate- Show how
LazyFramerecords work first and only produces data after.collect()Collect / Query Plan / Pushdown
- Use
.collect()as the execution boundary and inspect plans with.explain()to verify what Polars will actually run- Demonstrate predicate pushdown and projection pushdown so filters and column pruning move into the scan instead of happening after a full read
Eager to Lazy Conversion / Streaming Mode / Profile
- Convert eager DataFrames to lazy pipelines with
.lazy(), then materialize once at the edge- Use streaming mode for memory-constrained execution when the plan supports it, and
lf.profile()to time the actual bottlenecking nodesPandas vs Polars Lazy Benchmark / Performance & Optimization
- Benchmark filter, group-by, join, and sort operations across Pandas and Polars at representative scales instead of relying on tiny toy examples
- Contrast vectorized column operations with Python loops and
.apply()/.map_elements()to show where DataFrame performance actually comes fromMemory Usage / Polars Architecture
- Measure memory honestly with
df.memory_usage(deep=True)ordf.estimated_size()so string/object columns do not get undercounted- Connect performance outcomes back to Polars’ Arrow memory model, Rust engine, multithreading, and optimizer rather than treating speedups as magic
Operations and safety
- When to use lazy execution: large file reads, multi-step production pipelines, memory-constrained workloads, and queries where pushdown or streaming can remove large amounts of wasted work
- When not to use: interactive EDA that needs immediate inspection, small datasets where optimizer overhead dominates, unsupported streaming operations, or debugging scenarios where deferred errors slow iteration
- Warnings:
.collect()inside loops reruns full plans, toy benchmarks do not generalize, shallow Pandas memory reporting undercounts strings,.apply()destroys vectorized speed, and streaming can fall back silently when unsupported nodes appear- Recommendations: 7 practices covering lazy scans for disk-backed data, mandatory plan inspection, realistic-scale benchmarks, profiling before tuning, replacing
.apply()with native expressions, accurate memory measurement, and the “build eager, ship lazy” workflow- Troubleshooting: 7 failure modes covering missing pushdown, out-of-memory collects, Python UDFs blocking scan optimizations, misleading benchmarks, disabled streaming, I/O-bound plans, and unexpectedly slow
.apply()
Glossary
Eager execution
An execution model where every DataFrame operation runs immediately and returns a materialized result right away.
It matters because Pandas and eager Polars DataFrames behave this way, making them easy to inspect but potentially wasteful on large disk-backed workloads.
Immediate work can be wasteful
Eager execution loads and processes data before the full intent of the pipeline is known. On large files, that can mean reading rows and columns you were about to discard.
Lazy execution
An execution model where operations are recorded first and only run when a terminal materialization step is requested.
It matters because the note centers on how Polars can optimize multi-step pipelines before touching most of the data.
Collect too early, lose the benefit
If you materialize after every step, you reduce a lazy pipeline back to eager behavior. Build the full plan first, then execute once.
LazyFrame
A Polars object representing a deferred query plan instead of an already materialized in-memory table.
It matters because all lazy scans, optimized filters, and execution-plan introspection in the note are built on
LazyFrame.Plan object, not data container
You cannot inspect row values from a LazyFrame directly the way you do with a DataFrame. First you inspect the plan or schema, then you collect.
.collect()
The LazyFrame method that triggers execution and returns a concrete Polars DataFrame.
It matters because it is the explicit boundary between planning and materialization throughout the note.
Avoid repeated collection
Collecting inside loops or helper functions can rerun the same expensive scan repeatedly. Keep collection at the outer edge of the workflow when possible.
Query plan
The internal representation of all operations recorded on a lazy pipeline before execution.
It matters because understanding performance in Polars requires reading what the optimizer will actually run, not just what the source code appears to say.
Optimized plans can differ from written order
Filters, projections, and other operations may be fused or moved. That is a feature, not a discrepancy, but you need to inspect the plan to verify it helped.
Predicate pushdown
An optimization that moves filters as close as possible to the data source so fewer rows are read and processed.
It matters because large lazy scans become practical only when irrelevant rows are skipped before full materialization.
Python UDFs block pushdown
If your filter logic depends on Python callbacks instead of native expressions, the optimizer often cannot push it down into the scan layer.
Projection pushdown
An optimization that reads only the columns actually needed by the query instead of loading the full schema eagerly.
It matters because wide analytical tables often contain many unused columns, and skipping them reduces I/O and memory pressure immediately.
Format matters
Projection pushdown is strongest on columnar formats such as Parquet. Text formats like CSV still impose much more scanning work even when you keep few columns.
Streaming mode
A Polars execution mode that processes supported plans in chunks instead of materializing the entire dataset at once.
It matters because it can make otherwise too-large lazy pipelines runnable on limited RAM.
Support is plan-dependent
Not every lazy query can stream. Sorts, certain joins, and other nodes may force a full materialization fallback.
profile()
A Polars method that executes a lazy plan and returns both the result and timing data for the underlying operations.
It matters because performance work should start with measured bottlenecks, not assumptions about which step must be slow.
Single-run timings can mislead
Profiling includes I/O, cache state, and system noise. Repeat important measurements before concluding that one operator is the true bottleneck.
Vectorization
Column-oriented execution using optimized native loops instead of per-row Python interpretation.
It matters because the note’s performance comparisons depend on keeping work inside vectorized DataFrame kernels whenever possible.
Compact Python is still Python
A short lambda can look elegant while destroying performance. Vectorization is about execution model, not code length.
Memory footprint
The total RAM consumed by a DataFrame, including both raw values and object overhead.
It matters because performance planning is impossible without knowing whether the data and intermediate results fit in available memory.
Shallow counts underreport object columns
Pandas string and object columns can consume far more memory than shallow pointer-based estimates imply. Use
deep=Truewhen measuring.
scan_parquet()/scan_csv()
Lazy Polars readers that create a
LazyFrameinstead of immediately loading file contents into a materialized DataFrame.They matter because pushdown and streaming start at the scan stage; eager readers cannot retroactively gain those benefits.
Lazy begins at the reader
If you read eagerly and then call
.lazy(), the data is already in memory. That can still help with later optimization, but it cannot undo the eager file read cost.
.explain()
A plan-inspection method that prints the optimized lazy query plan without executing it.
It matters because it is the fastest way to verify whether pushdown, projection pruning, and other optimizations actually activated.
Read the scan node carefully
The most revealing part of the plan is often the scan section: how many columns are projected, whether filters moved down, and whether the plan still looks broader than expected.
.apply()/.map_elements()
Python callback escape hatches that execute custom code row by row or element by element instead of staying inside the native expression engine.
They matter because they are the most common reason a DataFrame workflow performs far worse than expected.
Treat as temporary escape hatches
If a pipeline depends heavily on Python UDFs, optimization options collapse quickly. Replace them with native expressions whenever possible.
deep=True
A Pandas memory-reporting option that traverses Python object contents instead of counting only shallow array or pointer storage.
It matters because string-heavy DataFrames can otherwise look deceptively small during performance planning.
Use for honest measurement
Performance tuning without accurate memory numbers is guesswork.
deep=Trueis the difference between a rough pointer count and a realistic RAM estimate for object-heavy tables.
Runs the live example for this step.
import pandas as pd
import polars as pl
import polars.selectors as cs
import numpy as np
from pathlib import Path
from IPython.display import display, Markdown
html_formatter = get_ipython().display_formatter.formatters['text/html'] # type: ignore
html_formatter.for_type(pd.DataFrame, lambda df: df.to_html())
html_formatter.for_type(pd.Series, lambda s: s.to_frame().to_html())
pl.Config.set_tbl_rows(100)
pd.set_option("display.max_rows", 100)
DATA = Path("../data")
# Core datasets
ohlcv_pd = pd.read_parquet(DATA / "eurostoxx50_ohlcv.parquet") # 66K rows, daily OHLCV
ohlcv_pl = pl.read_parquet(DATA / "eurostoxx50_ohlcv.parquet")
dim_pd = pd.read_parquet(DATA / "index_dim.parquet") # 169 rows, stock metadata
dim_pl = pl.read_parquet(DATA / "index_dim.parquet")
scores_pd = pd.read_parquet(DATA / "scores_daily.parquet") # 466 rows, composite scores
scores_pl = pl.read_parquet(DATA / "scores_daily.parquet")
print(f"OHLCV: {ohlcv_pd.shape}, Dim: {dim_pd.shape}, Scores: {scores_pd.shape}")
import timeOHLCV: (66355, 12), Dim: (169, 26), Scores: (466, 36)
Eager: Immediate
Eager File Read
Polars | Eager read with read_parquet()
Eager execution loads ALL data
Eager execution loads ALL data into memory immediately
pl.read_parquet()andpd.read_parquet()load the entire file into RAM. For files larger than available memory, use lazy mode (pl.scan_parquet()) or Pandasread_parquet(columns=[...])to only load needed columns.
Use lazy scanning or column selection to limit memory usage
In Polars, replace
pl.read_parquet(path)withpl.scan_parquet(path)and chain.select()/.filter()before.collect()— Polars will apply projection and predicate pushdown automatically. In Pandas, passcolumns=[...]topd.read_parquet()to load only the columns you need.
Reads eurostoxx50_ohlcv.parquet eagerly with pl.read_parquet(), printing the resulting type and shape to confirm all 66,355 rows are fully loaded into a Polars DataFrame.
df = pl.read_parquet(DATA / "eurostoxx50_ohlcv.parquet")
print(f"Type: {type(df)}, Shape: {df.shape}")Type: <class ‘polars.dataframe.frame.DataFrame’>, Shape: (66355, 12)
Lazy: Deferred
The lazy-vs-eager distinction mirrors concepts elsewhere in the pipeline: dbt’s ephemeral models defer computation in the same way a LazyFrame does, while dbt run materializes results like .collect() — see dbt-materializations. BigQuery’s query planner applies similar predicate pushdown and projection pruning, covered in querying-and-cost-optimization.
flowchart LR A["pl.scan_parquet(path)<br/>pl.scan_csv(path)"] -->|"LazyFrame<br/>(no data)"| B["Build query plan"] B --> C[".filter(pl.col(...))"] C --> D[".select(...)"] D --> E[".group_by / .sort<br/>.with_columns"] E -->|"Optimizer rewrites plan"| F[".collect()"] F --> G["DataFrame<br/>(materialized)"] B2["pd.read_parquet(path)"] -->|"DataFrame<br/>(all data loaded)"| G2["Immediate execution<br/>(no optimization)"] style A fill:#1f2335,stroke:#7aa2f7,color:#c0caf5 style B fill:#1f2335,stroke:#7aa2f7,color:#c0caf5 style C fill:#1f2335,stroke:#e0af68,color:#c0caf5 style D fill:#1f2335,stroke:#e0af68,color:#c0caf5 style E fill:#1f2335,stroke:#e0af68,color:#c0caf5 style F fill:#1f2335,stroke:#f7768e,color:#f7768e style G fill:#1f2335,stroke:#9ece6a,color:#9ece6a style B2 fill:#1f2335,stroke:#f7768e,color:#c0caf5 style G2 fill:#1f2335,stroke:#565f89,color:#c0caf5
Lazy File Scanning
Polars | scan_parquet()
pl.scan_parquet(path) returns a LazyFrame — a description of work, not data. Call .collect_schema() to inspect the column names and types without reading any rows. The schema is derived from the Parquet file footer metadata.
Scans eurostoxx50_ohlcv.parquet without loading any rows, confirms the resulting type is LazyFrame, then calls collect_schema() to read the Parquet footer and return all 12 column names with their Arrow types.
lf = pl.scan_parquet(DATA / "eurostoxx50_ohlcv.parquet")
print(f"Type: {type(lf)}")
print(f"Schema: {lf.collect_schema()}")Type: <class ‘polars.lazyframe.frame.LazyFrame’> Schema: Schema({‘id’: Int64, ‘symbol’: String, ‘date’: Date, ‘open’: Float64, ‘high’: Float64, ‘low’: Float64, ‘close’: Float64, ‘adj_close’: Float64, ‘volume’: Int64, ‘dividends’: Float64, ‘stock_splits’: Float64, ‘is_filled’: Boolean})
Collect
Materializing a LazyFrame
Polars | .collect()
Lazy chains do nothing until
.collect()Forgetting
.collect()is the most common Polars mistake A LazyFrame does nothing until.collect()is called. If you assignlf.filter(...)to a variable and never collect, no computation happens. Unlike Pandas (where every operation runs immediately), Polars lazy chains must end with.collect()to materialize results.
Always end a Polars lazy chain with
.collect()Every
pl.scan_*()chain must terminate with.collect()to produce a DataFrame. Use type annotations (lf: pl.LazyFrame,df: pl.DataFrame) to catch missing.collect()calls at review time. If you need a partial result during development, chain.head(100).collect()first to verify the plan before collecting the full dataset.
Chains filter, select, sort, and head on a lazy scan of eurostoxx50_ohlcv.parquet, then materializes with .collect() to return the 10 most recent closing prices for ASML.AS.
result = (
pl.scan_parquet(DATA / "eurostoxx50_ohlcv.parquet")
.filter(pl.col("symbol") == "ASML.AS")
.select("symbol", "date", "close")
.sort("date", descending=True)
.head(10)
.collect()
)
display(result)| symbol | date | close |
|---|---|---|
| str | date | f64 |
| ASML.AS | 2026-03-12 | 1190.8 |
| ASML.AS | 2026-03-11 | 1198.8 |
| ASML.AS | 2026-03-10 | 1200.0 |
| ASML.AS | 2026-03-09 | 1147.6 |
| ASML.AS | 2026-03-06 | 1147.0 |
| ASML.AS | 2026-03-05 | 1186.0 |
| ASML.AS | 2026-03-04 | 1199.8 |
| ASML.AS | 2026-03-03 | 1161.8 |
| ASML.AS | 2026-03-02 | 1210.4 |
| ASML.AS | 2026-02-27 | 1233.4 |
Query Plan
Query Plan Inspection
Polars | explain()
lf.explain() returns the optimized query plan as a string without executing it. Read it to verify that Polars has applied predicate and projection pushdown. The key fields to look for:
PROJECT N/12 COLUMNS— only N columns will be read from disk (projection pushdown active)SELECTION: [...]— the filter predicate has been pushed into the scannerESTIMATED ROWS— Polars’ row count estimate before execution
Always check
.explain()before collecting on large datasetsOn a multi-GB Parquet file, call
lf.explain()first to verify thatPROJECTshows fewer columns than the total and thatSELECTIONcontains your filter. If you seePROJECT */N COLUMNSwith the full column count, your filter or select is not pushing down — check for unsupported expression types.
Builds a lazy plan filtering ASML.AS rows with close above 900 and selecting 3 of 12 columns, then calls explain() to print the optimized plan — confirming PROJECT 3/12 COLUMNS and both filter predicates merged into a single SELECTION clause.
lf = (
pl.scan_parquet(DATA / "eurostoxx50_ohlcv.parquet")
.filter(pl.col("symbol") == "ASML.AS")
.filter(pl.col("close") > 900)
.select("symbol", "date", "close")
)
print(lf.explain())Parquet SCAN [../data/eurostoxx50_ohlcv.parquet] PROJECT 3/12 COLUMNS SELECTION: [([(col(“symbol”)) == (“ASML.AS”)]) & ([(col(“close”)) > (900.0)])] ESTIMATED ROWS: 66355
Predicate Pushdown
Predicate Pushdown
Polars | Predicate pushdown
When you call .filter() on a LazyFrame, Polars moves the predicate into the file scanner at optimization time. For Parquet files, the scanner uses row group statistics to skip entire row groups that cannot satisfy the predicate — so unmatched rows are never deserialized into memory. The optimizer applies this even if you write the filter after a .select().
Pandas has no predicate pushdown
pd.read_parquet()loads all rows unconditionally. The only way to limit rows in Pandas is to read all data first, then filter withdf.query()or boolean indexing. To limit I/O in Pandas, usepd.read_parquet(path, filters=[...])which delegates pushdown to thepyarrowengine — but this is only available at read time, not as part of a chain.
Chains .select() before .filter() to demonstrate that the optimizer still pushes the symbol predicate into the Parquet scanner regardless of chain order — confirmed by explain() showing SELECTION: [(col("symbol")) == ("ASML.AS")].
lf = (
pl.scan_parquet(DATA / "eurostoxx50_ohlcv.parquet")
.select("symbol", "date", "close")
.filter(pl.col("symbol") == "ASML.AS")
)
print("Filter pushed to scan:")
print(lf.explain())Filter pushed to scan: Parquet SCAN [../data/eurostoxx50_ohlcv.parquet] PROJECT 3/12 COLUMNS SELECTION: [(col(“symbol”)) == (“ASML.AS”)] ESTIMATED ROWS: 66355
Projection Pushdown
Projection Pushdown
Polars | Projection pushdown
.select() on a LazyFrame tells Polars which columns are needed. At optimization time, the column list is pushed into the Parquet scanner, which reads only those byte ranges from disk — all other columns are completely skipped. The .explain() output shows PROJECT N/12 COLUMNS to confirm this is active.
Selects 2 of 12 columns from the OHLCV Parquet file via a lazy scan, calls explain() to confirm PROJECT 2/12 COLUMNS is active, then collects to verify the resulting shape is (66355, 2).
lf = pl.scan_parquet(DATA / "eurostoxx50_ohlcv.parquet").select("symbol", "close")
print("Only 2 columns read:")
print(lf.explain())
print(f"Result: {lf.collect().shape}")Only 2 columns read: Parquet SCAN [../data/eurostoxx50_ohlcv.parquet] PROJECT 2/12 COLUMNS ESTIMATED ROWS: 66355 Result: (66355, 2)
Eager to Lazy Conversion
Converting Eager to Lazy
Polars | .lazy()
Call .lazy() on an existing DataFrame to enter the lazy API. The conversion is free — no data is copied. Use this when you have already read data eagerly but want to apply further operations with query optimization before collecting. pl.col("name") is the expression API entry point — it references a column by name and is the foundation for all Polars filter, select, and transform expressions.
Reads the OHLCV dataset eagerly, then calls .lazy() to enter the lazy API and chains .filter() and .select() before .collect() — returning 1,331 ASML.AS rows with only the date and close columns.
df = pl.read_parquet(DATA / "eurostoxx50_ohlcv.parquet")
result = df.lazy().filter(pl.col("symbol") == "ASML.AS").select("date", "close").collect()
print(f"Result: {result.shape}")Result: (1331, 2)
Streaming Mode
Streaming Execution
Polars | collect(engine=“streaming”)
Streaming mode processes data in chunks instead of loading the full dataset into memory at once. Pass engine="streaming" to .collect() to activate it. Use streaming for datasets larger than available RAM or when you want bounded memory usage on long-running aggregations.
Streaming support is still incomplete
Not all operations support streaming. Unsupported nodes fall back to in-memory execution silently. Check
.explain(streaming=True)to see which plan nodes will stream. The new Polars streaming engine (introduced in v1) is more capable than the legacystreaming=Trueparameter from v0.x but remains under active development.
Confirm streaming in the plan before relying on it
Treat
engine="streaming"as a bounded-memory candidate, not a guarantee. Run.explain(streaming=True)first and make sure the critical scan, filter, and aggregation nodes remain on the streaming path before you depend on it in production.
Scans the OHLCV Parquet with engine="streaming", filters rows where close exceeds 500, groups by symbol to compute average close rounded to 2 decimals, sorts descending, and collects — returning the 7 symbols that consistently traded above 500.
result = (
pl.scan_parquet(DATA / "eurostoxx50_ohlcv.parquet")
.filter(pl.col("close") > 500)
.group_by("symbol").agg(pl.col("close").mean().round(2).alias("avg_close"))
.sort("avg_close", descending=True)
.collect(engine="streaming")
)
display(result.head(10))| symbol | avg_close |
|---|---|
| str | f64 |
| RMS.PA | 1761.56 |
| ADYEN.AS | 1545.98 |
| RHM.DE | 1230.09 |
| ASML.AS | 696.35 |
| MC.PA | 677.03 |
| ARGX.BR | 625.29 |
| MUV2.DE | 548.06 |
Profile
Query Profiling
Polars | .profile()
lf.profile() executes the query and returns a tuple of (result_df, timing_df). The timing_df contains one row per plan node with start and end timestamps in microseconds. Use it to identify which operation in a chain is the bottleneck before optimizing.
Profiles a chain that filters two symbols (ASML.AS and MC.PA), computes daily return percentage via with_columns, and groups by symbol for average return — returning both the result DataFrame and a timing DataFrame with microsecond start/end timestamps per plan node.
lf = (
pl.scan_parquet(DATA / "eurostoxx50_ohlcv.parquet")
.filter(pl.col("symbol").is_in(["ASML.AS", "MC.PA"]))
.with_columns(((pl.col("close") - pl.col("open")) / pl.col("open") * 100).alias("ret"))
.group_by("symbol").agg(pl.col("ret").mean().round(4).alias("avg_ret"))
)
result_df, timing_df = lf.profile()
display(result_df)
display(timing_df)| symbol | avg_ret |
|---|---|
| str | f64 |
| ASML.AS | -0.0162 |
| MC.PA | -0.0045 |
| node | start | end |
|---|---|---|
| str | u64 | u64 |
| optimization | 0 | 2054 |
| with_column(ret) | 2054 | 2222 |
| group_by(symbol) | 2227 | 2566 |
The start and end columns are in microseconds. Here, optimization took 2054 µs (plan rewriting), with_column(ret) took 168 µs, and group_by took 339 µs. Subtract end - start per row to find the slowest node — that is where to focus optimization effort.
Pandas vs Polars Lazy Benchmark
Head Query Benchmark
Pandas / Polars | Head query benchmark
Compares the same operation — filter one symbol, select three columns, sort by date descending, take top 10 — using Pandas eager execution vs Polars lazy execution. Pandas reads the full Parquet file then filters in memory. Polars scans with predicate and projection pushdown.
When to choose Polars lazy over Pandas for read queries?
For small DataFrames (< 100K rows, fits easily in memory), the difference is negligible and Pandas’ familiar API may be preferable. Choose Polars lazy when: (1) data is larger than memory or growing toward that limit, (2) the query reads from Parquet and you can exploit projection/predicate pushdown, (3) the operation is part of a scheduled pipeline where throughput matters, or (4) you need reproducible multi-threaded performance.
Times the identical head-10 query — filter ASML.AS, select 3 columns, sort descending by date — in Pandas (full eager read then filter) versus Polars lazy scanning with predicate and projection pushdown, printing both durations and the speedup ratio (5.1x in the sample run).
start = time.perf_counter()
pd.read_parquet(DATA / "eurostoxx50_ohlcv.parquet").query("symbol == 'ASML.AS'")[["symbol","date","close"]].sort_values("date", ascending=False).head(10)
pd_t = time.perf_counter() - start
start = time.perf_counter()
pl.scan_parquet(DATA / "eurostoxx50_ohlcv.parquet").filter(pl.col("symbol")=="ASML.AS").select("symbol","date","close").sort("date", descending=True).head(10).collect()
pl_t = time.perf_counter() - start
print(f"Pandas: {pd_t:.4f}s \nPolars lazy: {pl_t:.4f}s \nSpeedup: {pd_t/pl_t:.1f}x")Pandas: 0.0114s Polars lazy: 0.0023s Speedup: 5.1x
Summary
| Feature | Polars Eager | Polars Lazy | Pandas |
|---|---|---|---|
| Execution | Immediate | .collect() | Immediate |
| Optimization | None | Pushdown | None |
| Memory | Full | Streaming | Full |
| Query plan | No | .explain() | No |
| Profile | No | .profile() | No |
Performance & Optimization
Dataset Reload
Reloads both datasets into memory so the benchmark cells below have a clean baseline without any cached filtered subsets from earlier cells.
Runs the live example for this step.
ohlcv_pd=pd.read_parquet(DATA/"eurostoxx50_ohlcv.parquet")
ohlcv_pl=pl.read_parquet(DATA/"eurostoxx50_ohlcv.parquet")
print(f"Rows: {len(ohlcv_pd):,}")Rows: 66,355
Vectorized vs Loop
Row Iteration vs Vectorized Arithmetic
Pandas | iterrows() vs column arithmetic
DataFrame.iterrows() yields one Python dict per row, bypassing NumPy’s C-level vectorization entirely. Column arithmetic (df["a"] - df["b"]) dispatches to NumPy’s C implementation and processes all rows in a single SIMD-accelerated pass. The benchmark below measures iterrows over 1K rows vs column subtraction over the full 66K rows.
Polars has no iterrows equivalent
Polars DataFrames are immutable and expression-based — there is no row iteration API. All operations use
pl.col()expressions that execute in parallel across the full column in Rust. This design eliminates the anti-pattern at the API level.
Runs iterrows() over 1,000 rows computing close minus open per row in Python, then runs direct column subtraction over all 66,355 rows, printing both durations to quantify the cost of per-row Python dispatch versus a single C-level vectorized pass.
start=time.perf_counter()
results=[]
for _,row in ohlcv_pd.head(1000).iterrows():
results.append(row["close"]-row["open"])
bad=time.perf_counter()-start
start=time.perf_counter()
_=ohlcv_pd["close"]-ohlcv_pd["open"]
good=time.perf_counter()-start
print(f"iterrows (1K): {bad:.4f}s")
print(f"vectorized (66K): {good:.4f}s")iterrows (1K): 0.0116s vectorized (66K): 0.0003s
Why apply() Is Slow
The apply() Anti-Pattern
Pandas | apply(axis=1) anti-pattern
apply() is extremely slow
df.apply(axis=1)is 100-1000x slower than vectorized operations.apply()withaxis=1iterates row by row in Python — bypassing NumPy/C optimizations entirely. The example below shows a 743x speedup from vectorization. Everyapply(lambda r: ...)in production code is a performance bug. Rewrite using column arithmetic,.where(), ornp.select()for conditional logic.
Replace
apply(axis=1)with vectorized column arithmeticRewrite row-wise lambdas as direct column operations:
df["ret"] = (df["close"] - df["open"]) / df["open"] * 100. For conditional logic usenp.where()ornp.select()instead ofapply. In Polars, usepl.when().then().otherwise()— all operations execute in parallel across columns in native Rust with no Python overhead.
Applies a row-wise lambda computing daily return percentage (close−open)/open×100 over all 66K rows with apply(axis=1), then computes the same metric via direct column arithmetic, printing both durations and the 743x speedup.
start=time.perf_counter()
ohlcv_pd["ret_apply"]=ohlcv_pd.apply(lambda r:(r["close"]-r["open"])/r["open"]*100,axis=1)
apply_t=time.perf_counter()-start
start=time.perf_counter()
ohlcv_pd["ret_vec"]=(ohlcv_pd["close"]-ohlcv_pd["open"])/ohlcv_pd["open"]*100
vec_t=time.perf_counter()-start
print(f"apply: {apply_t:.4f}s\nvectorized: {vec_t:.4f}s\nSpeedup: {apply_t/vec_t:.0f}x")apply: 0.2317s vectorized: 0.0003s Speedup: 743x
Memory Usage
RAM Footprint Comparison
Pandas / Polars | RAM footprint comparison
Polars uses Apache Arrow as its in-memory format. Arrow stores data in typed, contiguous column buffers that are more compact than Pandas’ NumPy arrays, which add per-array Python object overhead and use 8-byte floats for integer columns that contain NaN.
Pandas uses NaN (float) for missing integers; Polars uses null
In Pandas, an integer column with any missing value is silently promoted to
float64to accommodateNaN. This doubles the memory footprint for integer columns with nulls and can cause silent precision loss for large integers. Polars uses a nativenulltype backed by a validity bitmask — integer columns stay asInt64regardless of nulls, with no type coercion.
Measures the in-memory size of the 66K-row OHLCV dataset in both libraries — memory_usage(deep=True) for Pandas and estimated_size("mb") for Polars — printing both in MB and the ratio to confirm Polars is 2.2x more memory-efficient.
mem_pd=ohlcv_pd.memory_usage(deep=True).sum()/1024/1024
mem_pl=ohlcv_pl.estimated_size("mb")
print(f"Pandas: {mem_pd:.2f} MB")
print(f"Polars: {mem_pl:.2f} MB")
print(f"Ratio: {mem_pd/mem_pl:.1f}x")Pandas: 11.65 MB Polars: 5.20 MB Ratio: 2.2x
Benchmark: Common Operations
Filter, GroupBy, and Sort Benchmarks
Pandas / Polars | Filter, GroupBy, Sort
Measures three core operations — single-column equality filter, group-by mean aggregation, and multi-column sort — side by side on the same 66K-row dataset. Polars benefits from multi-threaded execution and Apache Arrow’s cache-friendly columnar layout.
Benchmarks filter, group-by mean aggregation, and multi-column sort on the 66K-row OHLCV dataset with Pandas versus Polars, each in a separate timed code cell, printing per-operation durations and speedup ratios.
ops = {}
start = time.perf_counter()
_ = ohlcv_pd[ohlcv_pd["symbol"] == "ASML.AS"]
ops["pd_filter"] = time.perf_counter() - start
start = time.perf_counter()
_ = ohlcv_pl.filter(pl.col("symbol") == "ASML.AS")
ops["pl_filter"] = time.perf_counter() - start
print(f" pd_filter : {ops['pd_filter']:.4f}s")
print(f" pl_filter : {ops['pl_filter']:.4f}s")
print(f" Filter speedup: {ops['pd_filter'] / ops['pl_filter']:.1f}x")pd_filter : 0.0019s pl_filter : 0.0006s Filter speedup: 3.4x
Runs the live example for this step.
start = time.perf_counter()
_ = ohlcv_pd.groupby("symbol")["close"].mean()
ops["pd_groupby"] = time.perf_counter() - start
start = time.perf_counter()
_ = ohlcv_pl.group_by("symbol").agg(pl.col("close").mean())
ops["pl_groupby"] = time.perf_counter() - start
print(f" pd_groupby : {ops['pd_groupby']:.4f}s")
print(f" pl_groupby : {ops['pl_groupby']:.4f}s")
print(f" GroupBy speedup: {ops['pd_groupby'] / ops['pl_groupby']:.1f}x")pd_groupby : 0.0025s pl_groupby : 0.0013s GroupBy speedup: 1.9x
Runs the live example for this step.
start = time.perf_counter()
_ = ohlcv_pd.sort_values(["symbol", "date"])
ops["pd_sort"] = time.perf_counter() - start
start = time.perf_counter()
_ = ohlcv_pl.sort("symbol", "date")
ops["pl_sort"] = time.perf_counter() - start
print(f" pd_sort : {ops['pd_sort']:.4f}s")
print(f" pl_sort : {ops['pl_sort']:.4f}s")
print(f" Sort speedup: {ops['pd_sort'] / ops['pl_sort']:.1f}x")pd_sort : 0.0064s pl_sort : 0.0014s Sort speedup: 4.6x
Polars Architecture
Execution Model
Polars | Execution model
Polars is built on four pillars that together make it faster than Pandas for analytical workloads:
- Apache Arrow — columnar in-memory format; cache-friendly, zero-copy between Arrow-native systems (DuckDB, Pyarrow, Pandas 2.x Arrow backend)
- SIMD vector instructions — operations on column arrays use CPU vectorization (AVX2/AVX-512) to process multiple values per clock cycle
- Multi-threaded Rust engine — the thread pool size matches available CPU cores; group-by and sort operations partition work across threads automatically
- Lazy query optimization — the full pipeline is rewritten before execution: predicate pushdown, projection pushdown, common subexpression elimination
Pandas and Polars have no shared index
Pandas attaches an index to every DataFrame. The index enables label-based alignment in joins and assignments but is also a source of subtle bugs (misaligned index after filtering, accidental index-based join instead of column-based). Polars has no index — every operation is explicit and column-based. This makes Polars code more predictable but means you must use explicit join keys rather than relying on index alignment.
Runs the live example for this step.
print(f"Thread pool: {pl.thread_pool_size()}")
print(f"Polars version: {pl.__version__}")Thread pool: 16 Polars version: 1.39.3
Summary
| Aspect | Pandas | Polars |
|---|---|---|
| Engine | Single-threaded C | Multi-threaded Rust |
| Memory | NumPy arrays | Apache Arrow |
| Optimization | None | Query planning |
| apply() | Slow | Use expressions |
Common Traps and Safe Patterns
Collect Once Per Workload
Calling
.collect()inside a loop re-runs the full planIf a loop calls
lf.filter(...).collect()on each iteration, the entire read and filter pipeline runs from scratch every time. That turns lazy execution into repeated full-query work.
Build one lazy plan and collect once
Combine conditions into a single expression tree, then materialize the final result once. If you need several variants, keep the shared lazy base and branch it deliberately instead of collecting on every loop pass.
Benchmark at Production Scale
Toy benchmarks hide the real performance gap
A 1,000-row benchmark may show Pandas and Polars performing identically. At 1M+ rows, Polars’ multi-threaded Rust engine and query optimization often change the result completely.
Benchmark with realistic row counts and schema width
Measure on data sizes, column counts, and value distributions that look like production. That is the only way to judge whether lazy execution, threading, and pushdown are paying off.
Measure String Columns Deeply
Shallow memory accounting understates object-column cost
Pandas
df.memory_usage()counts only pointer sizes for object columns. For strings, the shallow estimate can miss most of the real Python-object memory.
Use
deep=Truewhen inspecting Pandas memoryCall
df.memory_usage(deep=True)in Pandas and compare it withdf.estimated_size()in Polars. That gives you a usable memory baseline before deciding whether the pipeline still fits comfortably in RAM.
Keep Computation Out of Python Row Loops
.apply()throws away vectorized executionA Python lambda applied row by row runs at Python speed. Once you move work into
.apply(axis=1)or similar callbacks, you give up the C/Rust execution path that makes DataFrame code fast.
Rewrite row logic as native expressions
Replace row-wise lambdas with column arithmetic, conditional expressions, or other native APIs. Treat Python UDFs as a temporary escape hatch, not the steady-state design.
Verify the Streaming Path
Streaming requests can still materialize the whole dataset
If a query contains an unsupported operation, Polars may fall back to a non-streaming plan. Assuming that
engine="streaming"is always honored is a memory-footgun.
Check
explain(streaming=True)before trusting bounded memoryInspect the plan and confirm the important scan, filter, and aggregation nodes stay on the streaming path. If they do not, reduce the query or accept that it will run as a normal in-memory collect.
Python Lazy API and Performance Recommendations
- Use lazy for anything that touches disk —
scan_parquet(),scan_csv(),scan_ndjson()enable predicate and projection pushdown that eager reads cannot match. - Inspect query plans — call
.explain()on every production query to verify that pushdown and fusion are active. If the plan shows a full scan where you expected pushdown, the filter expression may be too complex. - Benchmark at realistic scale — test with 1M+ rows, realistic column counts, and representative data distributions. Toy benchmarks mislead.
- Profile before optimizing — use
lf.profile()to identify the actual bottleneck. Optimizing the wrong step wastes effort. - Replace every
.apply()with a native expression — treat.apply()/.map_elements()as a temporary workaround, not a solution. Almost every Python lambda has a vectorized equivalent. - Measure memory with
deep=True— always usedf.memory_usage(deep=True)(Pandas) ordf.estimated_size()(Polars) to get accurate memory figures. - Build with eager, ship with lazy — explore interactively using eager DataFrames, then convert the final pipeline to lazy for production.
Troubleshooting and failure modes
Failure modes
lf.explain() still shows a full scan
When .explain() still reports PROJECT */12 COLUMNS or leaves the filter outside the scan node, the query is still reading too much data. Rewrite the filter with native expressions, move it before joins or aggregations, and verify pushdown on the next plan inspection.
Inspect a lazy Parquet scan that should push the filter into the scanner.
import polars as pl
from pathlib import Path
DATA = Path("C:/Users/aperi/My Drive/VAULT/data")
lf = (
pl.scan_parquet(DATA / "eurostoxx50_ohlcv.parquet")
.filter(pl.col("symbol") == "ASML.AS")
.select("symbol", "close")
)
print("\n".join(lf.explain().splitlines()[:3]))Parquet SCAN [C:/Users/aperi/My Drive/VAULT/data/eurostoxx50_ohlcv.parquet]
PROJECT 2/12 COLUMNS
SELECTION: [(col("symbol")) == ("ASML.AS")].collect() raises memory pressure
When .collect() fails or crawls because the result does not fit cleanly in RAM, keep the lazy pipeline intact and try a supported streaming collect before widening the scan again. If the plan cannot stream, reduce rows or columns earlier in the chain.
Collect the same scan in streaming mode to confirm the bounded-memory path still succeeds.
import polars as pl
from pathlib import Path
DATA = Path("C:/Users/aperi/My Drive/VAULT/data")
print(
pl.scan_parquet(DATA / "eurostoxx50_ohlcv.parquet")
.select("symbol", "close")
.collect(engine="streaming")
.shape
)(66355, 2)Python UDFs in filter() block pushdown
When a filter depends on a Python callback such as map_elements() or apply(), the optimizer cannot reason about it well enough to push it into the scan. Rewrite the predicate as a native expression on pl.col(...) so the scan node can stay optimizer-visible.
Show the native expression form that the optimizer can inspect and push down.
import polars as pl
expr = pl.col("close") > 900
print(f"Native expression: {expr}")Native expression: [(col("close")) > (dyn int: 900)]Toy benchmarks on 66,355 rows do not generalize
A benchmark that looks decisive on a single laptop-sized file can disappear at production scale, especially once I/O, cardinality, and wider schemas enter the picture. Measure on the largest realistic slice you can afford, not on a convenience sample.
Check the row count before trusting a speedup ratio from a small sample.
import pandas as pd
from pathlib import Path
DATA = Path("C:/Users/aperi/My Drive/VAULT/data")
ohlcv_pd = pd.read_parquet(DATA / "eurostoxx50_ohlcv.parquet")
print(f"Rows in sample: {ohlcv_pd.shape[0]:,}")Rows in sample: 66,355Streaming can still require explain(streaming=True)
If engine="streaming" does not behave as expected, inspect the plan rather than assuming the execution engine stayed on the chunked path. Unsupported operations can force a fallback, so verify the plan shape before relying on streaming for memory control.
Run a streaming-friendly aggregation to confirm the supported path produces a bounded result.
import polars as pl
from pathlib import Path
DATA = Path("C:/Users/aperi/My Drive/VAULT/data")
print(
pl.scan_parquet(DATA / "eurostoxx50_ohlcv.parquet")
.filter(pl.col("close") > 500)
.group_by("symbol")
.agg(pl.col("close").mean())
.collect(engine="streaming")
.shape
)(7, 2)lf.profile() points to the scan when I/O dominates
When the profile table shows optimization is cheap but scan or materialization costs remain large, the fastest win is usually narrower reads, better file layout, or faster storage. Use the timing rows to decide whether the bottleneck is planning, compute, or disk.
Profile a short lazy aggregation and inspect the timing rows that identify the slowest nodes.
import polars as pl
from pathlib import Path
DATA = Path("C:/Users/aperi/My Drive/VAULT/data")
lf = (
pl.scan_parquet(DATA / "eurostoxx50_ohlcv.parquet")
.filter(pl.col("symbol").is_in(["ASML.AS", "MC.PA"]))
.with_columns(((pl.col("close") - pl.col("open")) / pl.col("open") * 100).alias("ret"))
.group_by("symbol")
.agg(pl.col("ret").mean().round(4).alias("avg_ret"))
)
_, timing_df = lf.profile()
print(timing_df)shape: (3, 3)
┌──────────────────┬───────┬───────┐
│ node ┆ start ┆ end │
│ --- ┆ --- ┆ --- │
│ str ┆ u64 ┆ u64 │
╞══════════════════╪═══════╪═══════╡
│ optimization ┆ 0 ┆ 9256 │
│ with_column(ret) ┆ 9256 ┆ 11262 │
│ group_by(symbol) ┆ 11267 ┆ 14176 │
└──────────────────┴───────┴───────┘.apply(axis=1) is slow because it stays in Python
When a row-wise lambda still feels slow, replace it with a vectorized expression or a batch-oriented escape hatch like map_batches() if a native rewrite is not yet possible. The more you can keep inside columnar operations, the less Python overhead you pay.
Show the vectorized alternative that returns the same kind of per-row numeric differences without Python iteration.
import pandas as pd
from pathlib import Path
DATA = Path("C:/Users/aperi/My Drive/VAULT/data")
ohlcv_pd = pd.read_parquet(DATA / "eurostoxx50_ohlcv.parquet")
print((ohlcv_pd["close"] - ohlcv_pd["open"]).head(3).round(2).to_list())[-0.94, 0.28, 0.81]