Pandas vs Polars - Performance Comparison
Pandas has dominated Python data manipulation for over a decade. It's the default choice taught in bootcamps, used in tutorials, and embedded in countless production pipelines. But Pandas was...
Key Insights
- Polars consistently outperforms Pandas by 5-20x on operations like groupby, joins, and CSV parsing, with the gap widening as dataset size increases beyond 1 million rows.
- Memory efficiency is where Polars truly shines—lazy evaluation and Apache Arrow’s columnar format can reduce peak memory usage by 50-70% compared to eager Pandas operations.
- Migration isn’t all-or-nothing: Polars’
to_pandas()andfrom_pandas()methods let you incrementally adopt it for bottleneck operations while keeping existing Pandas code intact.
The DataFrame Landscape
Pandas has dominated Python data manipulation for over a decade. It’s the default choice taught in bootcamps, used in tutorials, and embedded in countless production pipelines. But Pandas was designed in 2008 when datasets fit comfortably in memory and single-threaded execution was acceptable.
Polars emerged in 2020 as a ground-up reimagining of the DataFrame concept. Written in Rust with Python bindings, it leverages Apache Arrow’s columnar memory format and implements lazy evaluation by default. These aren’t incremental improvements—they’re architectural decisions that fundamentally change performance characteristics.
The question isn’t whether Polars is faster. It is. The question is whether the performance gains justify the migration cost for your specific use case. Let’s find out with real benchmarks.
Setup and Environment
All benchmarks ran on an M2 MacBook Pro with 16GB RAM, using Python 3.11. Library versions:
- Pandas 2.1.4
- Polars 0.20.3
- PyArrow 14.0.2
import pandas as pd
import polars as pl
import numpy as np
import time
from functools import wraps
print(f"Pandas: {pd.__version__}")
print(f"Polars: {pl.__version__}")
def benchmark(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__}: {elapsed:.3f}s")
return result
return wrapper
# Generate synthetic dataset: 5 million rows
np.random.seed(42)
n_rows = 5_000_000
data = {
"id": np.arange(n_rows),
"category": np.random.choice(["A", "B", "C", "D", "E"], n_rows),
"region": np.random.choice(["North", "South", "East", "West"], n_rows),
"value": np.random.randn(n_rows) * 100,
"quantity": np.random.randint(1, 1000, n_rows),
"timestamp": pd.date_range("2020-01-01", periods=n_rows, freq="s"),
}
# Save as CSV and Parquet for I/O benchmarks
pd.DataFrame(data).to_csv("benchmark_data.csv", index=False)
pd.DataFrame(data).to_parquet("benchmark_data.parquet", index=False)
Five million rows is large enough to expose performance differences while still fitting in memory on modest hardware. Your production datasets may be larger, and the performance gaps will only widen.
Read/Write Performance
File I/O is often the first bottleneck in data pipelines. Let’s compare CSV and Parquet ingestion:
@benchmark
def pandas_read_csv():
return pd.read_csv("benchmark_data.csv")
@benchmark
def polars_read_csv():
return pl.read_csv("benchmark_data.csv")
@benchmark
def pandas_read_parquet():
return pd.read_parquet("benchmark_data.parquet")
@benchmark
def polars_read_parquet():
return pl.read_parquet("benchmark_data.parquet")
# Results on 5M row dataset:
# pandas_read_csv: 4.821s
# polars_read_csv: 0.847s (5.7x faster)
# pandas_read_parquet: 0.312s
# polars_read_parquet: 0.089s (3.5x faster)
Polars reads CSV files nearly 6x faster by parallelizing the parsing across CPU cores. Pandas’ CSV reader is single-threaded and written in C, which was fast in 2010 but can’t compete with modern parallel implementations.
Parquet performance is closer because both libraries leverage Apache Arrow under the hood, but Polars still wins by avoiding the conversion overhead to Pandas’ internal representation.
Write performance shows similar patterns:
@benchmark
def pandas_write_parquet(df):
df.to_parquet("output_pandas.parquet", index=False)
@benchmark
def polars_write_parquet(df):
df.write_parquet("output_polars.parquet")
# pandas_write_parquet: 1.243s
# polars_write_parquet: 0.398s (3.1x faster)
Core Operations Benchmark
Reading data is just the beginning. Let’s benchmark the operations that dominate most data pipelines:
Filtering
df_pandas = pd.read_parquet("benchmark_data.parquet")
df_polars = pl.read_parquet("benchmark_data.parquet")
@benchmark
def pandas_filter():
return df_pandas[
(df_pandas["category"] == "A") &
(df_pandas["value"] > 50)
]
@benchmark
def polars_filter():
return df_polars.filter(
(pl.col("category") == "A") &
(pl.col("value") > 50)
)
# pandas_filter: 0.089s
# polars_filter: 0.012s (7.4x faster)
GroupBy Aggregations
This is where Polars really flexes:
@benchmark
def pandas_groupby():
return df_pandas.groupby(["category", "region"]).agg({
"value": ["mean", "std", "min", "max"],
"quantity": "sum"
})
@benchmark
def polars_groupby():
return df_polars.group_by(["category", "region"]).agg([
pl.col("value").mean().alias("value_mean"),
pl.col("value").std().alias("value_std"),
pl.col("value").min().alias("value_min"),
pl.col("value").max().alias("value_max"),
pl.col("quantity").sum().alias("quantity_sum"),
])
# pandas_groupby: 0.287s
# polars_groupby: 0.031s (9.3x faster)
Joins
# Create a lookup table
lookup_pandas = pd.DataFrame({
"category": ["A", "B", "C", "D", "E"],
"category_name": ["Alpha", "Beta", "Charlie", "Delta", "Echo"],
"weight": [1.0, 1.5, 2.0, 2.5, 3.0]
})
lookup_polars = pl.from_pandas(lookup_pandas)
@benchmark
def pandas_join():
return df_pandas.merge(lookup_pandas, on="category", how="left")
@benchmark
def polars_join():
return df_polars.join(lookup_polars, on="category", how="left")
# pandas_join: 0.412s
# polars_join: 0.067s (6.1x faster)
Sorting
@benchmark
def pandas_sort():
return df_pandas.sort_values(["category", "value"], ascending=[True, False])
@benchmark
def polars_sort():
return df_polars.sort(["category", "value"], descending=[False, True])
# pandas_sort: 1.847s
# polars_sort: 0.234s (7.9x faster)
Memory Efficiency
Raw speed is one thing, but memory efficiency determines whether your pipeline runs at all on constrained infrastructure:
import tracemalloc
def measure_memory(func):
tracemalloc.start()
result = func()
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"{func.__name__}: Peak memory: {peak / 1024 / 1024:.1f} MB")
return result
def pandas_pipeline():
df = pd.read_csv("benchmark_data.csv")
df = df[df["value"] > 0]
df = df.groupby("category").agg({"value": "mean", "quantity": "sum"})
return df
def polars_lazy_pipeline():
return (
pl.scan_csv("benchmark_data.csv")
.filter(pl.col("value") > 0)
.group_by("category")
.agg([
pl.col("value").mean(),
pl.col("quantity").sum()
])
.collect()
)
# measure_memory(pandas_pipeline)
# pandas_pipeline: Peak memory: 1847.3 MB
# measure_memory(polars_lazy_pipeline)
# polars_lazy_pipeline: Peak memory: 612.4 MB (67% reduction)
The scan_csv function is the key here. Instead of loading the entire file into memory, Polars builds a query plan and optimizes it before execution. The filter operation gets pushed down, so rows with value <= 0 never materialize in memory.
This lazy evaluation pattern is Polars’ killer feature for large datasets. You can chain arbitrary operations, and Polars will figure out the most efficient execution order.
API and Migration Considerations
Polars’ API is intentionally different from Pandas. It’s more explicit, more consistent, and designed around method chaining. Here’s how common patterns translate:
# Pandas: Column selection
df_pandas[["category", "value"]]
# Polars:
df_polars.select(["category", "value"])
# Pandas: Conditional column creation
df_pandas["value_category"] = np.where(
df_pandas["value"] > 0, "positive", "negative"
)
# Polars:
df_polars.with_columns(
pl.when(pl.col("value") > 0)
.then(pl.lit("positive"))
.otherwise(pl.lit("negative"))
.alias("value_category")
)
# Pandas: Multiple aggregations with rename
df_pandas.groupby("category")["value"].agg(
avg_value="mean",
total_value="sum"
)
# Polars:
df_polars.group_by("category").agg([
pl.col("value").mean().alias("avg_value"),
pl.col("value").sum().alias("total_value"),
])
# Pandas: Apply custom function
df_pandas["value"].apply(lambda x: x ** 2)
# Polars (avoid apply when possible, use expressions):
df_polars.select(pl.col("value").pow(2))
The Polars API is more verbose in simple cases but scales better for complex transformations. The expression system (pl.col(), pl.when(), etc.) enables optimizations that aren’t possible with Pandas’ approach.
Interoperability is straightforward:
# Pandas to Polars
df_polars = pl.from_pandas(df_pandas)
# Polars to Pandas
df_pandas = df_polars.to_pandas()
This lets you migrate incrementally. Identify your slowest operations, convert those to Polars, and keep everything else in Pandas.
When to Use Which
Here’s my decision framework after migrating several production pipelines:
Use Polars when:
- Dataset exceeds 1 million rows
- Memory constraints are tight
- Pipeline involves heavy groupby/join operations
- You’re building new code without legacy dependencies
- Execution time directly impacts user experience or costs
Stick with Pandas when:
- Dataset is under 100K rows (overhead dominates)
- Heavy reliance on ecosystem libraries (scikit-learn, statsmodels)
- Team has deep Pandas expertise and tight deadlines
- Exploratory analysis in notebooks (Pandas’ display is still better)
- You need features Polars lacks (some time series operations, MultiIndex)
| Aspect | Pandas | Polars |
|---|---|---|
| CSV Read (5M rows) | 4.8s | 0.8s |
| GroupBy Aggregation | 0.29s | 0.03s |
| Peak Memory | 1847 MB | 612 MB |
| Lazy Evaluation | No | Yes |
| Parallel Execution | Limited | Native |
| Ecosystem Maturity | Excellent | Growing |
| Learning Curve | Low | Medium |
The performance numbers don’t lie. For data-intensive applications, Polars delivers 5-10x speedups with significantly lower memory usage. The API is different but learnable in a few days.
Start with your slowest pipeline. Benchmark it. If Polars cuts execution time from 10 minutes to 1 minute, that’s worth the migration effort. If it cuts 2 seconds to 0.3 seconds, probably not.
The future is clearly moving toward Arrow-native, Rust-based tools. Polars is production-ready today, and investing in it now positions your codebase well for the next decade of data engineering.