Pandas Performance Optimization
Speed up pandas operations 10-100x — vectorization, chunked processing, efficient dtypes, and when to switch to Polars or DuckDB.
Real-World Scenario
A data pipeline processing 10M rows of event logs takes 45 minutes with iterrows()-based logic. After vectorization, efficient dtypes, and chunked I/O, it runs in 3 minutes. The same pipeline on Polars runs in 25 seconds.
Dtype Optimization
import pandas as pd
import numpy as np
# Generate a realistic dataset
rng = np.random.default_rng(42)
n = 1_000_000
df = pd.DataFrame({
"user_id": rng.integers(1, 100_000, n),
"event": rng.choice(["click", "view", "purchase", "scroll"], n),
"category": rng.choice(["electronics", "clothing", "food", "sports", "home"], n),
"amount": rng.uniform(0, 500, n).round(2),
"quantity": rng.integers(1, 10, n),
"rating": rng.integers(1, 6, n).astype(np.int8), # 1-5 fits in int8
"is_mobile": rng.integers(0, 2, n).astype(bool),
})
print(f"Before optimization: {df.memory_usage(deep=True).sum() / 1024**2:.1f} MB")
def optimize_dtypes(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
for col in df.select_dtypes("int64").columns:
col_min, col_max = df[col].min(), df[col].max()
for dtype in [np.int8, np.int16, np.int32]:
if np.iinfo(dtype).min <= col_min and col_max <= np.iinfo(dtype).max:
df[col] = df[col].astype(dtype)
break
for col in df.select_dtypes("float64").columns:
if df[col].between(-3.4e38, 3.4e38).all():
df[col] = df[col].astype(np.float32)
for col in df.select_dtypes("object").columns:
n_unique = df[col].nunique()
if n_unique / len(df) < 0.5: # < 50% unique values → use category
df[col] = df[col].astype("category")
return df
df_opt = optimize_dtypes(df)
print(f"After optimization: {df_opt.memory_usage(deep=True).sum() / 1024**2:.1f} MB")
# Show dtypes after
print("\nDtype changes:")
for col in df.columns:
if df[col].dtype != df_opt[col].dtype:
print(f" {col}: {df[col].dtype} → {df_opt[col].dtype}")
Vectorization vs iterrows
import pandas as pd
import numpy as np
import timeit
rng = np.random.default_rng(42)
df = pd.DataFrame({
"price": rng.uniform(10, 500, 100_000),
"quantity": rng.integers(1, 20, 100_000),
"discount": rng.uniform(0, 0.3, 100_000),
"category": rng.choice(["A", "B", "C"], 100_000),
})
# ── SLOW: iterrows ────────────────────────────────────────────────────
def slow_revenue(df: pd.DataFrame) -> pd.Series:
revenues = []
for _, row in df.iterrows():
rev = row["price"] * row["quantity"] * (1 - row["discount"])
if row["category"] == "A":
rev *= 1.1
revenues.append(rev)
return pd.Series(revenues)
# ── FAST: vectorized ──────────────────────────────────────────────────
def fast_revenue(df: pd.DataFrame) -> pd.Series:
base = df["price"] * df["quantity"] * (1 - df["discount"])
return base * np.where(df["category"] == "A", 1.1, 1.0)
# Benchmark
t_slow = timeit.timeit(lambda: slow_revenue(df.head(10_000)), number=1)
t_fast = timeit.timeit(lambda: fast_revenue(df), number=10) / 10
print(f"iterrows (10k rows): {t_slow:.3f}s")
print(f"vectorized (100k rows): {t_fast:.4f}s")
print(f"Speedup: >{t_slow / t_fast * 10:.0f}x")
# Verify same results
result_slow = slow_revenue(df.head(100))
result_fast = fast_revenue(df.head(100))
assert np.allclose(result_slow.values, result_fast.values, rtol=1e-5)
print("Results match ✓")
Efficient String Operations
import pandas as pd
import numpy as np
import re
rng = np.random.default_rng(42)
n = 500_000
emails = pd.Series([
f"user{i}@{'gmail' if i % 3 == 0 else 'yahoo'}.com"
for i in rng.integers(1, 100_000, n)
])
# ── SLOW: Python apply ────────────────────────────────────────────────
import timeit
def slow_domain(s: pd.Series) -> pd.Series:
return s.apply(lambda x: x.split("@")[1].split(".")[0])
# ── FAST: .str accessor (vectorized C operations) ─────────────────────
def fast_domain(s: pd.Series) -> pd.Series:
return s.str.split("@").str[1].str.split(".").str[0]
# Even faster: single regex extract
def fastest_domain(s: pd.Series) -> pd.Series:
return s.str.extract(r"@(\w+)\.", expand=False)
t_slow = timeit.timeit(lambda: slow_domain(emails.head(10_000)), number=1)
t_fast = timeit.timeit(lambda: fast_domain(emails), number=5) / 5
t_fastest = timeit.timeit(lambda: fastest_domain(emails), number=5) / 5
print(f"apply (10k): {t_slow:.3f}s")
print(f".str chain (500k): {t_fast:.3f}s")
print(f"str.extract (500k): {t_fastest:.3f}s")
Chunked Processing for Large Files
import pandas as pd
import numpy as np
from pathlib import Path
def process_large_csv(
input_path: str,
output_path: str,
chunk_size: int = 100_000,
) -> dict:
"""Process a large CSV in chunks — never loads the full file into memory."""
stats = {"rows_processed": 0, "rows_kept": 0, "chunks": 0}
first_chunk = True
for chunk in pd.read_csv(
input_path,
chunksize=chunk_size,
dtype={"user_id": np.int32, "amount": np.float32}, # specify dtypes for efficiency
parse_dates=["timestamp"],
):
# Process: filter + aggregate per chunk
chunk = chunk[chunk["amount"] > 0]
chunk["revenue"] = chunk["amount"] * chunk.get("quantity", 1)
# Write chunk to output (append after first chunk)
chunk.to_csv(
output_path,
mode="w" if first_chunk else "a",
header=first_chunk,
index=False,
)
stats["rows_processed"] += len(chunk) + (chunk["amount"] <= 0).sum()
stats["rows_kept"] += len(chunk)
stats["chunks"] += 1
first_chunk = False
return stats
# Demo with synthetic data
def create_sample_csv(path: str, n: int = 1_000_000):
rng = np.random.default_rng(42)
df = pd.DataFrame({
"user_id": rng.integers(1, 10_000, n).astype(np.int32),
"amount": (rng.uniform(-10, 500, n)).astype(np.float32),
"quantity": rng.integers(1, 10, n),
"timestamp": pd.date_range("2024-01-01", periods=n, freq="s"),
})
df.to_csv(path, index=False)
print(f"Created {path}: {n:,} rows")
Path("/tmp/large.csv").parent.mkdir(exist_ok=True)
create_sample_csv("/tmp/large.csv", 500_000)
stats = process_large_csv("/tmp/large.csv", "/tmp/output.csv", chunk_size=50_000)
print(f"Stats: {stats}")
When to Use Polars
# pip install polars
import polars as pl
import pandas as pd
import numpy as np
import timeit
# Generate data
rng = np.random.default_rng(42)
n = 2_000_000
data = {
"user_id": rng.integers(1, 100_000, n),
"event": np.where(rng.random(n) > 0.5, "purchase", "view"),
"amount": rng.uniform(0, 500, n).round(2),
"category": rng.choice(["A", "B", "C", "D"], n),
}
df_pd = pd.DataFrame(data)
df_pl = pl.DataFrame(data)
# Task: filter purchases, group by user and category, compute mean amount
def pandas_query(df: pd.DataFrame) -> pd.DataFrame:
return (
df[df["event"] == "purchase"]
.groupby(["user_id", "category"])["amount"]
.mean()
.reset_index()
)
def polars_query(df: pl.DataFrame) -> pl.DataFrame:
return (
df.filter(pl.col("event") == "purchase")
.group_by(["user_id", "category"])
.agg(pl.col("amount").mean())
)
t_pd = timeit.timeit(lambda: pandas_query(df_pd), number=3) / 3
t_pl = timeit.timeit(lambda: polars_query(df_pl), number=3) / 3
print(f"Pandas: {t_pd:.3f}s")
print(f"Polars: {t_pl:.3f}s")
print(f"Speedup: {t_pd/t_pl:.1f}x")
# Polars lazy evaluation (query planning optimization)
result = (
df_pl.lazy()
.filter(pl.col("event") == "purchase")
.group_by(["user_id", "category"])
.agg([
pl.col("amount").mean().alias("avg_amount"),
pl.col("amount").sum().alias("total_amount"),
pl.col("amount").count().alias("n_purchases"),
])
.filter(pl.col("n_purchases") >= 3) # only users with 3+ purchases
.sort("total_amount", descending=True)
.limit(10)
.collect() # execute the whole plan at once
)
print(f"\nTop 10 users by total spend:\n{result}") Frequently Asked Questions
Why is iterrows() so slow and what should I replace it with?
iterrows() loops over rows in Python, which is O(n) in pure Python — no vectorization. A DataFrame with 1M rows takes seconds. Replace with: vectorized column operations (df['col'] + 1), np.where() or np.select() for conditionals, apply() as a last resort, or groupby+transform for group-wise operations. Vectorized operations run in C, typically 100-1000x faster.
When should I use pandas vs Polars vs DuckDB?
Pandas: standard choice, vast ecosystem, familiar API. Polars: 10-50x faster for many operations, better memory efficiency, built-in parallelism — use when pandas is a bottleneck on large datasets. DuckDB: SQL interface, best for analytical queries on files (Parquet, CSV), joins, and aggregations at scales where pandas OOM. Polars is the fastest Python DataFrame library.