Chapter 24
pandas vs Polars: DataFrame Library Benchmark
pandas vs Polars: DataFrame Library Benchmark
Docker image: ml4t
Purpose: Compare pandas and Polars for financial data operations typical in ML for trading pipelines. This run measures the pinned environment (pandas 2.3.3, Polars 1.41+); the version-detection cell below reports whether the pandas-3.0 performance features (Copy-on-Write, PyArrow strings) are active.
Learning Objectives:
- Understand performance characteristics of each library for different operations
- Know when to use pandas vs Polars based on operation type and data scale
- Recognize what pandas 3.0's Copy-on-Write and PyArrow strings change, and detect whether the running pandas has them enabled
- Measure memory efficiency for large financial datasets
Book Reference: Chapter 2, Section 2.4 (Storing Data) — engine choice trade-offs alongside file and database benchmarks.
Prerequisites: Familiarity with pandas/Polars basics; existing storage benchmarks.
Key Categories Tested
| Category | Operations | Financial Use Case |
|---|---|---|
| A: Rolling | SMA, EMA, rolling std, Sharpe | Time-series features |
| B: GroupBy | OHLCV resampling, cross-sectional stats | Bar construction |
| C: Window | Z-scores, percentile ranks, lags | Normalized features |
| D: Filtering | Multi-condition predicates | Options chain filtering |
| E: Joins | ASOF (trade-quote), anti-joins | Tick data matching |
| F: Lazy/Streaming | Parquet scan, predicate pushdown | Large file processing |
| G: Memory | Peak usage, allocation patterns | Resource constraints |
| H: Strings | Contains, extract, replace | Ticker manipulation |
Quick Start
# Development (S scale)
BENCHMARK_SCALE=S docker compose run --rm ml4t python 02_financial_data_universe/22_pandas_polars_benchmark.py
# Standard benchmark (L scale)
BENCHMARK_SCALE=L docker compose run --rm ml4t python 02_financial_data_universe/22_pandas_polars_benchmark.py
# Scale test (XL scale)
BENCHMARK_SCALE=XL docker compose run --rm ml4t python 02_financial_data_universe/22_pandas_polars_benchmark.pySetup and Version Detection
"""Pandas vs Polars Benchmark — systematic performance comparison across financial data operations."""
import gc
import warnings
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import polars as pl
import psutil
from IPython.display import display
from plotly.subplots import make_subplots
from utils.reproducibility import set_global_seeds
from utils.storage_benchmarks import (
ACTIVE_SCALE,
BENCHMARK_DIR,
N_ROWS_PER_SYMBOL,
N_SYMBOLS,
RESULTS_DIR,
TIMING_RUNS,
estimate_memory_mb,
generate_ohlcv_data,
generate_tick_data,
get_scale_config,
time_operation,
)
from utils.style import COLORS
warnings.filterwarnings("ignore")# Production defaults — Papermill injects overrides for CI
SEED = 42set_global_seeds(SEED)Version and Feature Detection
pandas 3.0 introduces changes that affect performance, and the cell below reports whether the installed pandas has them turned on (this pinned run is pandas 2.3.3, so they are not):
- Copy-on-Write (CoW): default in 3.0 — internal views with copy-on-modify semantics
- PyArrow-backed strings: default string dtype in 3.0 — better memory and string operations
- New
pd.col()API: cleaner column references in assign/groupby
# Version detection
PANDAS_VERSION = pd.__version__
POLARS_VERSION = pl.__version__
print("=" * 70)
print("DATAFRAME LIBRARY BENCHMARK")
print("=" * 70)
print(f"\npandas version: {PANDAS_VERSION}")
print(f"Polars version: {POLARS_VERSION}")
# pandas 3.0 feature detection
PANDAS_MAJOR = int(PANDAS_VERSION.split(".")[0])
IS_PANDAS_3 = PANDAS_MAJOR >= 3
# Check Copy-on-Write status (enabled by default in pandas 3.0)
try:
COW_ENABLED = pd.options.mode.copy_on_write
except AttributeError:
COW_ENABLED = False
# Check for PyArrow string dtype (default in pandas 3.0)
try:
test_series = pd.Series(["test"])
PYARROW_STRINGS = "pyarrow" in str(test_series.dtype) or test_series.dtype == "string"
except Exception:
PYARROW_STRINGS = False
print("\npandas 3.0 features:")
print(f" Copy-on-Write: {'enabled' if COW_ENABLED else 'disabled'}")
print(f" PyArrow strings: {'yes' if PYARROW_STRINGS else 'no'}")
# Configure Polars streaming (opt-in to new engine in 1.37+)
POLARS_STREAMING = False
try:
pl.Config.set_engine_affinity(engine="streaming")
POLARS_STREAMING = True
print("\nPolars streaming engine: enabled")
except Exception:
print("\nPolars streaming engine: not available (requires 1.37+)")Output
====================================================================== DATAFRAME LIBRARY BENCHMARK ====================================================================== pandas version: 2.3.3 Polars version: 1.41.1 pandas 3.0 features: Copy-on-Write: disabled PyArrow strings: no Polars streaming engine: enabled
Data Generation
We use the same synthetic OHLCV and tick data generators as the storage benchmarks to ensure comparable results across all benchmarks.
scale_cfg = get_scale_config(ACTIVE_SCALE)
print(f"\nScale: {ACTIVE_SCALE} ({scale_cfg['target_memory']} target)")
print(f"OHLCV: {N_SYMBOLS} symbols × {N_ROWS_PER_SYMBOL:,} rows/symbol")
print("\n=== Generating synthetic data ===\n")
# Generate OHLCV data (Polars native)
ohlcv_pl = generate_ohlcv_data(n_symbols=N_SYMBOLS, n_rows=N_ROWS_PER_SYMBOL)
total_rows = len(ohlcv_pl)
print(f"OHLCV: {total_rows:,} rows ({estimate_memory_mb(ohlcv_pl):.1f} MB)")
# Convert to pandas (triggers CoW in pandas 3.0)
ohlcv_pd = ohlcv_pl.to_pandas()
print(f"pandas memory: {ohlcv_pd.memory_usage(deep=True).sum() / 1e6:.1f} MB")
# Generate tick data for join benchmarks
trades_pl, quotes_pl = generate_tick_data(
n_symbols=min(N_SYMBOLS, 50), # Limit symbols for tick data
seed=42,
)
n_trades = len(trades_pl)
n_quotes = len(quotes_pl)
print(f"Trades: {n_trades:,} rows")
print(f"Quotes: {n_quotes:,} rows")
# Convert tick data to pandas
trades_pd = trades_pl.to_pandas()
quotes_pd = quotes_pl.to_pandas()
# Store results
results = []Output
Scale: S (1MB target) OHLCV: 10 symbols × 1,000 rows/symbol === Generating synthetic data === OHLCV: 10,000 rows (0.6 MB) pandas memory: 1.2 MB Trades: 5,000 rows Quotes: 25,000 rows
Helper Functions
Force materialization to ensure fair timing comparisons. Both libraries use lazy evaluation in some contexts (Polars explicitly, pandas via CoW).
def force_eval_pandas(df: pd.DataFrame) -> None:
"""Force pandas DataFrame evaluation by touching all data."""
# Numeric columns: sum
numeric_cols = df.select_dtypes(include=[np.number]).columns
if len(numeric_cols) > 0:
_ = df[numeric_cols].sum().sum()
# String columns: length (only actual string types)
str_cols = df.select_dtypes(include=["object", "string"]).columns
for col in str_cols[:2]: # Limit to avoid slow string ops
try:
if df[col].dtype == "object" or "string" in str(df[col].dtype):
_ = df[col].astype(str).str.len().sum()
except Exception:
pass # Skip if not actually string-likedef force_eval_polars(df: pl.DataFrame | pl.LazyFrame) -> pl.DataFrame:
"""Force Polars DataFrame evaluation."""
if isinstance(df, pl.LazyFrame):
df = df.collect()
# Touch numeric columns
numeric_cols = [c for c in df.columns if df[c].dtype in (pl.Float64, pl.Int64)]
if numeric_cols:
_ = df.select([pl.col(c).sum() for c in numeric_cols[:5]]).to_dict()
return dfBenchmark Runner
Time an operation on both libraries and collect results.
def benchmark_operation(
name: str,
category: str,
pandas_func,
polars_func,
n_runs: int = TIMING_RUNS,
) -> dict:
"""Benchmark an operation on both libraries.
Returns dict with timing results for both libraries.
"""
# pandas benchmark
gc.collect()
pd_time, pd_result = time_operation(pandas_func, n_runs=n_runs)
# Polars benchmark
gc.collect()
pl_time, pl_result = time_operation(polars_func, n_runs=n_runs)
# Calculate speedup
speedup = pd_time / pl_time if pl_time > 0 else float("inf")
result = {
"category": category,
"operation": name,
"pandas_time": pd_time,
"polars_time": pl_time,
"speedup": speedup,
}
print(f" {name}: pandas={pd_time:.4f}s, polars={pl_time:.4f}s, speedup={speedup:.1f}x")
return resultCategory A: Rolling Calculations
Rolling window operations are fundamental to time-series feature engineering. We test single-window, multi-horizon, and compound calculations (Sharpe ratio).
print("\n" + "=" * 70)
print("CATEGORY A: ROLLING CALCULATIONS")
print("=" * 70)
rolling_results = []Output
====================================================================== CATEGORY A: ROLLING CALCULATIONS ======================================================================
A1: Simple Rolling Mean (20-day SMA)
Basic moving average - the foundation of many trading signals.
def pd_rolling_mean():
result = ohlcv_pd.groupby("symbol")["close"].rolling(20).mean().reset_index(drop=True)
_ = result.sum() # Force evaluation
return resultdef pl_rolling_mean():
"""Compute 20-day rolling mean per symbol using Polars window expressions."""
result = ohlcv_pl.with_columns(pl.col("close").rolling_mean(20).over("symbol").alias("sma_20"))
_ = result.select(pl.col("sma_20").sum()).item()
return result
r = benchmark_operation("rolling_mean_20", "A_rolling", pd_rolling_mean, pl_rolling_mean)
rolling_results.append(r)Output
rolling_mean_20: pandas=0.0012s, polars=0.0012s, speedup=1.0x
A2: Rolling Standard Deviation (Volatility)
Volatility estimation - critical for risk management and signal normalization.
def pd_rolling_std():
result = ohlcv_pd.groupby("symbol")["close"].rolling(20).std().reset_index(drop=True)
_ = result.sum()
return resultdef pl_rolling_std():
"""Compute 20-day rolling standard deviation per symbol for volatility estimation."""
result = ohlcv_pl.with_columns(pl.col("close").rolling_std(20).over("symbol").alias("vol_20"))
_ = result.select(pl.col("vol_20").sum()).item()
return result
r = benchmark_operation("rolling_std_20", "A_rolling", pd_rolling_std, pl_rolling_std)
rolling_results.append(r)Output
rolling_std_20: pandas=0.0012s, polars=0.0012s, speedup=1.0x
A3: Multi-Horizon Rolling (1, 5, 21, 63, 126, 252 days)
Real feature engineering requires multiple lookback windows simultaneously. This tests the ability to compute many windows in a single pass.
HORIZONS = [1, 5, 21, 63, 126, 252]
def pd_multi_horizon():
result = ohlcv_pd.copy()
for h in HORIZONS:
result[f"ret_{h}"] = result.groupby("symbol")["close"].pct_change(h)
force_eval_pandas(result)
return resultdef pl_multi_horizon():
"""Compute returns at six horizons in a single with_columns call using Polars expressions."""
# Polars: all horizons in single with_columns call
result = ohlcv_pl.with_columns(
[pl.col("close").pct_change(h).over("symbol").alias(f"ret_{h}") for h in HORIZONS]
)
_ = result.select([pl.col(f"ret_{h}").sum() for h in HORIZONS]).to_dict()
return result
r = benchmark_operation("multi_horizon_returns", "A_rolling", pd_multi_horizon, pl_multi_horizon)
rolling_results.append(r)Output
multi_horizon_returns: pandas=0.0083s, polars=0.0019s, speedup=4.3x
A4: Rolling Sharpe Ratio
Compound calculation: rolling mean / rolling std. Tests chained operations.
def pd_rolling_sharpe():
result = ohlcv_pd.copy()
returns = result.groupby("symbol")["close"].pct_change()
result["sharpe"] = (returns.rolling(63).mean() / returns.rolling(63).std()) * np.sqrt(252)
force_eval_pandas(result)
return resultdef pl_rolling_sharpe():
"""Compute 63-day rolling Sharpe ratio via chained Polars window expressions."""
result = ohlcv_pl.with_columns(
pl.col("close").pct_change().over("symbol").alias("returns")
).with_columns(
(
pl.col("returns").rolling_mean(63).over("symbol")
/ pl.col("returns").rolling_std(63).over("symbol")
)
.mul(np.sqrt(252))
.alias("sharpe")
)
_ = result.select(pl.col("sharpe").sum()).item()
return result
r = benchmark_operation("rolling_sharpe_63", "A_rolling", pd_rolling_sharpe, pl_rolling_sharpe)
rolling_results.append(r)Output
rolling_sharpe_63: pandas=0.0033s, polars=0.0021s, speedup=1.5x
A5: Exponential Moving Average
EMA with span=20 - popular for trend-following signals.
def pd_ewm():
result = (
ohlcv_pd.groupby("symbol")["close"].ewm(span=20, adjust=False).mean().reset_index(drop=True)
)
_ = result.sum()
return resultdef pl_ewm():
"""Compute exponential moving average (span=20) per symbol using Polars ewm_mean."""
result = ohlcv_pl.with_columns(
pl.col("close").ewm_mean(span=20, adjust=False).over("symbol").alias("ema_20")
)
_ = result.select(pl.col("ema_20").sum()).item()
return result
r = benchmark_operation("ewm_span_20", "A_rolling", pd_ewm, pl_ewm)
rolling_results.append(r)
results.extend(rolling_results)Output
ewm_span_20: pandas=0.0011s, polars=0.0010s, speedup=1.1x
Category B: GroupBy Aggregations
GroupBy operations are essential for cross-sectional analysis and resampling.
print("\n" + "=" * 70)
print("CATEGORY B: GROUPBY AGGREGATIONS")
print("=" * 70)
groupby_results = []Output
====================================================================== CATEGORY B: GROUPBY AGGREGATIONS ======================================================================
B1: OHLCV Resampling (1-min to daily)
Aggregate minute bars to daily bars - common in bar construction pipelines.
def pd_resample():
result = (
ohlcv_pd.groupby([ohlcv_pd["timestamp"].dt.date, "symbol"])
.agg(
{
"open": "first",
"high": "max",
"low": "min",
"close": "last",
"volume": "sum",
}
)
.reset_index()
)
force_eval_pandas(result)
return resultdef pl_resample():
"""Resample OHLCV to daily bars using Polars group_by with first/last/min/max/sum aggregations."""
result = ohlcv_pl.group_by([pl.col("timestamp").dt.date().alias("timestamp"), "symbol"]).agg(
[
pl.col("open").first(),
pl.col("high").max(),
pl.col("low").min(),
pl.col("close").last(),
pl.col("volume").sum(),
]
)
force_eval_polars(result)
return result
r = benchmark_operation("ohlcv_resample_daily", "B_groupby", pd_resample, pl_resample)
groupby_results.append(r)Output
ohlcv_resample_daily: pandas=0.0035s, polars=0.0014s, speedup=2.6x
B2: Cross-Sectional Statistics by Date
Compute market-wide statistics for each timestamp.
def pd_cross_sectional():
result = ohlcv_pd.groupby("timestamp").agg(
{
"close": ["mean", "std", "min", "max"],
"volume": ["sum", "mean"],
}
)
result.columns = ["_".join(col) for col in result.columns]
return result.reset_index()def pl_cross_sectional():
"""Compute cross-sectional statistics (mean, std, min, max) per timestamp using Polars."""
result = ohlcv_pl.group_by("timestamp").agg(
[
pl.col("close").mean().alias("close_mean"),
pl.col("close").std().alias("close_std"),
pl.col("close").min().alias("close_min"),
pl.col("close").max().alias("close_max"),
pl.col("volume").sum().alias("volume_sum"),
pl.col("volume").mean().alias("volume_mean"),
]
)
force_eval_polars(result)
return result
r = benchmark_operation(
"cross_sectional_stats", "B_groupby", pd_cross_sectional, pl_cross_sectional
)
groupby_results.append(r)Output
cross_sectional_stats: pandas=0.0016s, polars=0.0013s, speedup=1.3x
B3: Symbol-Level Statistics
Per-symbol summary statistics across all time periods.
def pd_symbol_stats():
result = ohlcv_pd.groupby("symbol").agg(
{
"close": ["mean", "std", "min", "max", "count"],
"volume": ["sum", "mean"],
"high": "max",
"low": "min",
}
)
result.columns = ["_".join(col) for col in result.columns]
return result.reset_index()def pl_symbol_stats():
"""Compute per-symbol summary statistics (close, volume, high, low) using Polars group_by."""
result = ohlcv_pl.group_by("symbol").agg(
[
pl.col("close").mean().alias("close_mean"),
pl.col("close").std().alias("close_std"),
pl.col("close").min().alias("close_min"),
pl.col("close").max().alias("close_max"),
pl.col("close").count().alias("close_count"),
pl.col("volume").sum().alias("volume_sum"),
pl.col("volume").mean().alias("volume_mean"),
pl.col("high").max().alias("high_max"),
pl.col("low").min().alias("low_min"),
]
)
force_eval_polars(result)
return result
r = benchmark_operation("symbol_stats", "B_groupby", pd_symbol_stats, pl_symbol_stats)
groupby_results.append(r)
results.extend(groupby_results)Output
symbol_stats: pandas=0.0020s, polars=0.0014s, speedup=1.4x
Category C: Window Functions
Window functions compute values relative to other rows in a group. These are essential for cross-sectional normalization.
print("\n" + "=" * 70)
print("CATEGORY C: WINDOW FUNCTIONS")
print("=" * 70)
window_results = []Output
====================================================================== CATEGORY C: WINDOW FUNCTIONS ======================================================================
C1: Cross-Sectional Z-Score
Normalize returns relative to cross-section at each timestamp.
def pd_zscore():
result = ohlcv_pd.copy()
result["returns"] = result.groupby("symbol")["close"].pct_change()
grouped = result.groupby("timestamp")["returns"]
result["zscore"] = (result["returns"] - grouped.transform("mean")) / grouped.transform("std")
force_eval_pandas(result)
return resultdef pl_zscore():
"""Compute cross-sectional z-score of returns at each timestamp using Polars .over() windows."""
result = ohlcv_pl.with_columns(
pl.col("close").pct_change().over("symbol").alias("returns")
).with_columns(
(
(pl.col("returns") - pl.col("returns").mean().over("timestamp"))
/ pl.col("returns").std().over("timestamp")
).alias("zscore")
)
_ = result.select(pl.col("zscore").sum()).item()
return result
r = benchmark_operation("cross_sectional_zscore", "C_window", pd_zscore, pl_zscore)
window_results.append(r)Output
cross_sectional_zscore: pandas=0.0039s, polars=0.0036s, speedup=1.1x
C2: Percentile Rank
Rank each symbol's return within the cross-section.
def pd_rank():
result = ohlcv_pd.copy()
result["returns"] = result.groupby("symbol")["close"].pct_change()
result["rank"] = result.groupby("timestamp")["returns"].rank(pct=True)
force_eval_pandas(result)
return resultdef pl_rank():
"""Compute percentile rank of returns within each timestamp cross-section using Polars."""
result = (
ohlcv_pl.with_columns(pl.col("close").pct_change().over("symbol").alias("returns"))
.with_columns(pl.col("returns").rank().over("timestamp").alias("rank_raw"))
.with_columns(
(pl.col("rank_raw") / pl.col("rank_raw").max().over("timestamp")).alias("rank_pct")
)
)
_ = result.select(pl.col("rank_pct").sum()).item()
return result
r = benchmark_operation("percentile_rank", "C_window", pd_rank, pl_rank)
window_results.append(r)Output
percentile_rank: pandas=0.0046s, polars=0.0109s, speedup=0.4x
C3: Lagged Values
Create multiple lag columns (1, 5, 21 days) - common for autoregressive features.
LAGS = [1, 5, 21]
def pd_lags():
result = ohlcv_pd.copy()
for lag in LAGS:
result[f"close_lag_{lag}"] = result.groupby("symbol")["close"].shift(lag)
force_eval_pandas(result)
return resultdef pl_lags():
"""Create multiple lag columns (1, 5, 21 days) per symbol using Polars shift with .over()."""
result = ohlcv_pl.with_columns(
[pl.col("close").shift(lag).over("symbol").alias(f"close_lag_{lag}") for lag in LAGS]
)
_ = result.select([pl.col(f"close_lag_{lag}").sum() for lag in LAGS]).to_dict()
return result
r = benchmark_operation("lagged_values", "C_window", pd_lags, pl_lags)
window_results.append(r)
results.extend(window_results)Output
lagged_values: pandas=0.0034s, polars=0.0011s, speedup=3.1x
Category D: Filtering
Filter operations select subsets of data based on conditions. Complex predicates are common in options chain processing.
print("\n" + "=" * 70)
print("CATEGORY D: FILTERING")
print("=" * 70)
filter_results = []Output
====================================================================== CATEGORY D: FILTERING ======================================================================
D1: Simple Price Filter
# Precompute price threshold (median)
price_threshold = float(ohlcv_pl.select(pl.col("close").median()).item())
def pd_simple_filter():
result = ohlcv_pd[ohlcv_pd["close"] > price_threshold]
force_eval_pandas(result)
return resultdef pl_simple_filter():
"""Filter rows where close exceeds median price threshold using Polars filter."""
result = ohlcv_pl.filter(pl.col("close") > price_threshold)
force_eval_polars(result)
return result
r = benchmark_operation("simple_filter", "D_filter", pd_simple_filter, pl_simple_filter)
filter_results.append(r)Output
simple_filter: pandas=0.0014s, polars=0.0005s, speedup=2.9x
D2: Multi-Condition Filter
Combine price, volume, and symbol conditions.
# Get list of symbols for filtering
symbol_list = ohlcv_pl.select("symbol").unique().head(N_SYMBOLS // 2)["symbol"].to_list()
volume_threshold = float(ohlcv_pl.select(pl.col("volume").median()).item())
def pd_multi_filter():
result = ohlcv_pd[
(ohlcv_pd["close"] > price_threshold)
& (ohlcv_pd["volume"] > volume_threshold)
& (ohlcv_pd["symbol"].isin(symbol_list))
]
force_eval_pandas(result)
return resultdef pl_multi_filter():
"""Apply compound filter on price, volume, and symbol membership using Polars boolean expressions."""
result = ohlcv_pl.filter(
(pl.col("close") > price_threshold)
& (pl.col("volume") > volume_threshold)
& (pl.col("symbol").is_in(symbol_list))
)
force_eval_polars(result)
return result
r = benchmark_operation("multi_condition_filter", "D_filter", pd_multi_filter, pl_multi_filter)
filter_results.append(r)Output
multi_condition_filter: pandas=0.0012s, polars=0.0011s, speedup=1.1x
D3: Range Filter (Options-Style)
Simulate filtering an options chain by moneyness and expiry.
def pd_range_filter():
# Simulate: price between 95-105% of reference, volume in range
ref_price = price_threshold
result = ohlcv_pd[
(ohlcv_pd["close"] >= ref_price * 0.95)
& (ohlcv_pd["close"] <= ref_price * 1.05)
& (ohlcv_pd["volume"] >= volume_threshold * 0.5)
& (ohlcv_pd["volume"] <= volume_threshold * 2.0)
]
force_eval_pandas(result)
return resultdef pl_range_filter():
"""Filter by price and volume ranges using Polars is_between for options-style moneyness bands."""
ref_price = price_threshold
result = ohlcv_pl.filter(
pl.col("close").is_between(ref_price * 0.95, ref_price * 1.05)
& pl.col("volume").is_between(volume_threshold * 0.5, volume_threshold * 2.0)
)
force_eval_polars(result)
return result
r = benchmark_operation("range_filter", "D_filter", pd_range_filter, pl_range_filter)
filter_results.append(r)
results.extend(filter_results)Output
range_filter: pandas=0.0016s, polars=0.0009s, speedup=1.9x
Category E: Joins
Join operations are critical for tick data processing (trade-quote matching) and panel data operations.
print("\n" + "=" * 70)
print("CATEGORY E: JOINS")
print("=" * 70)
join_results = []Output
====================================================================== CATEGORY E: JOINS ======================================================================
E1: ASOF Join (Trade-Quote Matching)
Match each trade to the most recent quote - fundamental for tick data analysis.
# Sort data for ASOF join (required)
# pandas merge_asof requires left keys to be sorted by the "on" column
trades_pd_sorted = trades_pd.sort_values("timestamp").reset_index(drop=True)
quotes_pd_sorted = quotes_pd.sort_values("timestamp").reset_index(drop=True)
# Polars requires sort by both by and on columns
trades_pl_sorted = trades_pl.sort(["symbol", "timestamp"])
quotes_pl_sorted = quotes_pl.sort(["symbol", "timestamp"])
def pd_asof_join():
result = pd.merge_asof(
trades_pd_sorted,
quotes_pd_sorted,
on="timestamp",
by="symbol",
direction="backward",
)
force_eval_pandas(result)
return resultdef pl_asof_join():
"""Match trades to most recent quotes via Polars join_asof with backward strategy."""
result = trades_pl_sorted.join_asof(
quotes_pl_sorted,
on="timestamp",
by="symbol",
strategy="backward",
)
force_eval_polars(result)
return result
r = benchmark_operation("asof_join", "E_join", pd_asof_join, pl_asof_join)
join_results.append(r)Output
asof_join: pandas=0.0032s, polars=0.0011s, speedup=3.0x
E2: Anti-Join (Find Unmatched Trades)
Find trades without matching quotes - useful for data quality checks.
pandas 3.0 introduces how='left_anti' in merge.
def pd_anti_join():
# pandas 3.0 anti-join (or fallback for older versions)
if IS_PANDAS_3:
try:
result = (
pd.merge(
trades_pd_sorted,
quotes_pd_sorted[["timestamp", "symbol"]].drop_duplicates(),
on=["timestamp", "symbol"],
how="left",
indicator=True,
)
.query("_merge == 'left_only'")
.drop("_merge", axis=1)
)
except Exception:
# Fallback
merged = trades_pd_sorted.merge(
quotes_pd_sorted[["timestamp", "symbol"]].drop_duplicates(),
on=["timestamp", "symbol"],
how="left",
indicator=True,
)
result = merged[merged["_merge"] == "left_only"].drop("_merge", axis=1)
else:
merged = trades_pd_sorted.merge(
quotes_pd_sorted[["timestamp", "symbol"]].drop_duplicates(),
on=["timestamp", "symbol"],
how="left",
indicator=True,
)
result = merged[merged["_merge"] == "left_only"].drop("_merge", axis=1)
force_eval_pandas(result)
return resultdef pl_anti_join():
"""Find trades without matching quotes using Polars native anti-join."""
result = trades_pl_sorted.join(
quotes_pl_sorted.select(["timestamp", "symbol"]).unique(),
on=["timestamp", "symbol"],
how="anti",
)
force_eval_polars(result)
return result
r = benchmark_operation("anti_join", "E_join", pd_anti_join, pl_anti_join)
join_results.append(r)Output
anti_join: pandas=0.0063s, polars=0.0019s, speedup=3.3x
E3: Inner Join
Standard inner join for combining related tables.
# Create a smaller lookup table for join benchmark
symbols_df_pd = pd.DataFrame(
{
"symbol": ohlcv_pd["symbol"].unique(),
"sector": np.random.choice(["Tech", "Finance", "Healthcare", "Energy"], size=N_SYMBOLS),
}
)
symbols_df_pl = pl.DataFrame(
{
"symbol": ohlcv_pl.select("symbol").unique()["symbol"],
"sector": np.random.choice(["Tech", "Finance", "Healthcare", "Energy"], size=N_SYMBOLS),
}
)
def pd_inner_join():
result = ohlcv_pd.merge(symbols_df_pd, on="symbol", how="inner")
force_eval_pandas(result)
return resultdef pl_inner_join():
"""Inner-join OHLCV with sector lookup table using Polars join on symbol."""
result = ohlcv_pl.join(symbols_df_pl, on="symbol", how="inner")
force_eval_polars(result)
return result
r = benchmark_operation("inner_join", "E_join", pd_inner_join, pl_inner_join)
join_results.append(r)
results.extend(join_results)Output
inner_join: pandas=0.0045s, polars=0.0010s, speedup=4.7x
Category F: Lazy Evaluation and Streaming
Test lazy evaluation benefits on parquet files. This is where Polars typically shows largest advantages through predicate pushdown.
print("\n" + "=" * 70)
print("CATEGORY F: LAZY/STREAMING")
print("=" * 70)
lazy_results = []
# Save data to parquet for lazy benchmarks
parquet_path = BENCHMARK_DIR / f"ohlcv_{ACTIVE_SCALE.lower()}.parquet"
ohlcv_pl.write_parquet(parquet_path)
print(f"Parquet file: {parquet_path.stat().st_size / 1e6:.1f} MB")Output
====================================================================== CATEGORY F: LAZY/STREAMING ====================================================================== Parquet file: 0.4 MB
F1: Lazy Filter (Predicate Pushdown)
Filter during scan - Polars can push predicates into the parquet reader.
def pd_lazy_filter():
# pandas: must read all, then filter
df = pd.read_parquet(parquet_path)
result = df[df["close"] > price_threshold]
force_eval_pandas(result)
return resultdef pl_lazy_filter():
"""Scan parquet with predicate pushdown, filtering during read via Polars lazy API."""
# Polars: predicate pushdown - filter during read
result = pl.scan_parquet(parquet_path).filter(pl.col("close") > price_threshold).collect()
force_eval_polars(result)
return result
r = benchmark_operation("lazy_filter", "F_lazy", pd_lazy_filter, pl_lazy_filter)
lazy_results.append(r)Output
lazy_filter: pandas=0.0029s, polars=0.0011s, speedup=2.6x
F2: Column Projection
Read only needed columns - both libraries optimize this.
def pd_column_projection():
df = pd.read_parquet(parquet_path, columns=["timestamp", "symbol", "close", "volume"])
force_eval_pandas(df)
return dfdef pl_column_projection():
"""Read only selected columns from parquet using Polars lazy scan with column projection."""
df = pl.scan_parquet(parquet_path).select(["timestamp", "symbol", "close", "volume"]).collect()
force_eval_polars(df)
return df
r = benchmark_operation("column_projection", "F_lazy", pd_column_projection, pl_column_projection)
lazy_results.append(r)Output
column_projection: pandas=0.0029s, polars=0.0010s, speedup=2.8x
F3: Combined Filter + Aggregation (Query Optimization)
Complex query that benefits from Polars' query optimizer.
def pd_combined_query():
df = pd.read_parquet(parquet_path)
result = (
df[df["close"] > price_threshold]
.groupby("symbol")
.agg({"close": "mean", "volume": "sum"})
.reset_index()
)
force_eval_pandas(result)
return resultdef pl_combined_query():
"""Filter and aggregate in one lazy query, leveraging Polars query optimizer."""
result = (
pl.scan_parquet(parquet_path)
.filter(pl.col("close") > price_threshold)
.group_by("symbol")
.agg(
[
pl.col("close").mean(),
pl.col("volume").sum(),
]
)
.collect()
)
force_eval_polars(result)
return result
r = benchmark_operation("combined_query", "F_lazy", pd_combined_query, pl_combined_query)
lazy_results.append(r)
results.extend(lazy_results)Output
combined_query: pandas=0.0031s, polars=0.0019s, speedup=1.7x
Category G: Memory Efficiency
Measure memory usage during operations. Polars typically uses less memory due to its columnar layout and streaming capabilities.
print("\n" + "=" * 70)
print("CATEGORY G: MEMORY EFFICIENCY")
print("=" * 70)
memory_results = []Output
====================================================================== CATEGORY G: MEMORY EFFICIENCY ======================================================================
G1: Baseline Memory Usage
process = psutil.Process()
# pandas memory
gc.collect()
mem_before = process.memory_info().rss / 1e6
_ = ohlcv_pd.copy() # Copy triggers CoW in pandas 3.0
mem_after = process.memory_info().rss / 1e6
pd_mem = mem_after - mem_before
# Polars memory
gc.collect()
mem_before = process.memory_info().rss / 1e6
_ = ohlcv_pl.clone()
mem_after = process.memory_info().rss / 1e6
pl_mem = mem_after - mem_before
print("Copy/Clone operation memory:")
print(f" pandas: {pd_mem:.1f} MB")
print(f" Polars: {pl_mem:.1f} MB")
# Estimated DataFrame memory
pd_estimated = ohlcv_pd.memory_usage(deep=True).sum() / 1e6
pl_estimated = ohlcv_pl.estimated_size("mb")
print("\nDataFrame estimated size:")
print(f" pandas: {pd_estimated:.1f} MB")
print(f" Polars: {pl_estimated:.1f} MB")
print(f" Ratio: {pd_estimated / pl_estimated:.2f}x")
memory_results.append(
{
"category": "G_memory",
"operation": "df_estimated_size",
"pandas_time": pd_estimated, # Using time fields for memory (MB)
"polars_time": pl_estimated,
"speedup": pd_estimated / pl_estimated if pl_estimated > 0 else 1.0,
}
)Output
Copy/Clone operation memory: pandas: 0.0 MB Polars: 0.0 MB DataFrame estimated size: pandas: 1.2 MB Polars: 0.6 MB Ratio: 1.82x
Category H: String Operations
String operations are often a bottleneck. pandas 3.0's PyArrow strings should improve performance here.
print("\n" + "=" * 70)
print("CATEGORY H: STRING OPERATIONS")
print("=" * 70)
string_results = []Output
====================================================================== CATEGORY H: STRING OPERATIONS ======================================================================
H1: String Contains
def pd_str_contains():
result = ohlcv_pd[ohlcv_pd["symbol"].str.contains("SYM_0", regex=False)]
force_eval_pandas(result)
return resultdef pl_str_contains():
"""Filter rows by literal substring match on symbol using Polars str.contains."""
result = ohlcv_pl.filter(pl.col("symbol").str.contains("SYM_0", literal=True))
force_eval_polars(result)
return result
r = benchmark_operation("str_contains", "H_string", pd_str_contains, pl_str_contains)
string_results.append(r)Output
str_contains: pandas=0.0025s, polars=0.0005s, speedup=5.3x
H2: String Replace
def pd_str_replace():
result = ohlcv_pd.copy()
result["symbol_new"] = result["symbol"].str.replace("SYM_", "SYMBOL_", regex=False)
force_eval_pandas(result)
return resultdef pl_str_replace():
"""Replace substring in symbol column using Polars str.replace with literal mode."""
result = ohlcv_pl.with_columns(
pl.col("symbol").str.replace("SYM_", "SYMBOL_", literal=True).alias("symbol_new")
)
force_eval_polars(result)
return result
r = benchmark_operation("str_replace", "H_string", pd_str_replace, pl_str_replace)
string_results.append(r)Output
str_replace: pandas=0.0047s, polars=0.0008s, speedup=6.1x
H3: String Extract (Pattern Matching)
def pd_str_extract():
result = ohlcv_pd.copy()
result["symbol_num"] = result["symbol"].str.extract(r"SYM_(\d+)", expand=False)
force_eval_pandas(result)
return resultdef pl_str_extract():
"""Extract numeric suffix from symbol via regex capture group using Polars str.extract."""
result = ohlcv_pl.with_columns(
pl.col("symbol").str.extract(r"SYM_(\d+)", group_index=1).alias("symbol_num")
)
force_eval_polars(result)
return result
r = benchmark_operation("str_extract", "H_string", pd_str_extract, pl_str_extract)
string_results.append(r)
results.extend(string_results)Output
str_extract: pandas=0.0053s, polars=0.0009s, speedup=6.2x
Results Summary
print("\n" + "=" * 70)
print("BENCHMARK RESULTS SUMMARY")
print("=" * 70)
# Convert to DataFrame
results_df = pl.DataFrame(results)
# Add memory results if available
if memory_results:
memory_df = pl.DataFrame(memory_results)
results_df = pl.concat([results_df, memory_df])
# Summary by category
print("\n### By Category (Mean Speedup)")
category_summary = (
results_df.group_by("category")
.agg(
[
pl.col("speedup").mean().alias("mean_speedup"),
pl.col("speedup").min().alias("min_speedup"),
pl.col("speedup").max().alias("max_speedup"),
pl.len().alias("n_ops"),
]
)
.sort("mean_speedup", descending=True)
)
display(category_summary)
# Overall statistics
print("\n### Overall Statistics")
overall_speedup = results_df.select(pl.col("speedup").mean()).item()
print(f"Mean speedup (Polars vs pandas): {overall_speedup:.1f}x")
operations_faster = results_df.filter(pl.col("speedup") > 1.0).height
operations_slower = results_df.filter(pl.col("speedup") < 1.0).height
print(f"Operations where Polars is faster: {operations_faster}/{len(results_df)}")
print(f"Operations where pandas is faster: {operations_slower}/{len(results_df)}")
# Detailed results
print("Detailed Results (sorted by speedup):")
display(results_df.sort("speedup", descending=True))Output
====================================================================== BENCHMARK RESULTS SUMMARY ====================================================================== ### By Category (Mean Speedup)
shape: (8, 5) ┌───────────┬──────────────┬─────────────┬─────────────┬───────┐ │ category ┆ mean_speedup ┆ min_speedup ┆ max_speedup ┆ n_ops │ │ --- ┆ --- ┆ --- ┆ --- ┆ --- │ │ str ┆ f64 ┆ f64 ┆ f64 ┆ u32 │ ╞═══════════╪══════════════╪═════════════╪═════════════╪═══════╡ │ H_string ┆ 5.872602 ┆ 5.30789 ┆ 6.206668 ┆ 3 │ │ E_join ┆ 3.63847 ┆ 2.95285 ┆ 4.678021 ┆ 3 │ │ F_lazy ┆ 2.379486 ┆ 1.677788 ┆ 2.816734 ┆ 3 │ │ D_filter ┆ 1.926734 ┆ 1.05555 ┆ 2.868141 ┆ 3 │ │ G_memory ┆ 1.815652 ┆ 1.815652 ┆ 1.815652 ┆ 1 │ │ A_rolling ┆ 1.78242 ┆ 0.970304 ┆ 4.287955 ┆ 5 │ │ B_groupby ┆ 1.758083 ┆ 1.272597 ┆ 2.575381 ┆ 3 │ │ C_window ┆ 1.531532 ┆ 0.42252 ┆ 3.098696 ┆ 3 │ └───────────┴──────────────┴─────────────┴─────────────┴───────┘
| category | mean_speedup | min_speedup | max_speedup | n_ops |
|---|---|---|---|---|
| str | f64 | f64 | f64 | u32 |
| "H_string" | 5.872602 | 5.30789 | 6.206668 | 3 |
| "E_join" | 3.63847 | 2.95285 | 4.678021 | 3 |
| "F_lazy" | 2.379486 | 1.677788 | 2.816734 | 3 |
| "D_filter" | 1.926734 | 1.05555 | 2.868141 | 3 |
| "G_memory" | 1.815652 | 1.815652 | 1.815652 | 1 |
| "A_rolling" | 1.78242 | 0.970304 | 4.287955 | 5 |
| "B_groupby" | 1.758083 | 1.272597 | 2.575381 | 3 |
| "C_window" | 1.531532 | 0.42252 | 3.098696 | 3 |
### Overall Statistics Mean speedup (Polars vs pandas): 2.6x Operations where Polars is faster: 21/24 Operations where pandas is faster: 3/24 Detailed Results (sorted by speedup):
shape: (24, 5) ┌───────────┬────────────────────────┬─────────────┬─────────────┬──────────┐ │ category ┆ operation ┆ pandas_time ┆ polars_time ┆ speedup │ │ --- ┆ --- ┆ --- ┆ --- ┆ --- │ │ str ┆ str ┆ f64 ┆ f64 ┆ f64 │ ╞═══════════╪════════════════════════╪═════════════╪═════════════╪══════════╡ │ H_string ┆ str_extract ┆ 0.005289 ┆ 0.000852 ┆ 6.206668 │ │ H_string ┆ str_replace ┆ 0.004747 ┆ 0.000778 ┆ 6.103249 │ │ H_string ┆ str_contains ┆ 0.002528 ┆ 0.000476 ┆ 5.30789 │ │ E_join ┆ inner_join ┆ 0.004472 ┆ 0.000956 ┆ 4.678021 │ │ A_rolling ┆ multi_horizon_returns ┆ 0.008313 ┆ 0.001939 ┆ 4.287955 │ │ … ┆ … ┆ … ┆ … ┆ … │ │ C_window ┆ cross_sectional_zscore ┆ 0.003852 ┆ 0.003588 ┆ 1.073382 │ │ D_filter ┆ multi_condition_filter ┆ 0.001201 ┆ 0.001138 ┆ 1.05555 │ │ A_rolling ┆ rolling_std_20 ┆ 0.001183 ┆ 0.001193 ┆ 0.9918 │ │ A_rolling ┆ rolling_mean_20 ┆ 0.001155 ┆ 0.00119 ┆ 0.970304 │ │ C_window ┆ percentile_rank ┆ 0.00461 ┆ 0.01091 ┆ 0.42252 │ └───────────┴────────────────────────┴─────────────┴─────────────┴──────────┘
| category | operation | pandas_time | polars_time | speedup |
|---|---|---|---|---|
| str | str | f64 | f64 | f64 |
| "H_string" | "str_extract" | 0.005289 | 0.000852 | 6.206668 |
| "H_string" | "str_replace" | 0.004747 | 0.000778 | 6.103249 |
| "H_string" | "str_contains" | 0.002528 | 0.000476 | 5.30789 |
| "E_join" | "inner_join" | 0.004472 | 0.000956 | 4.678021 |
| "A_rolling" | "multi_horizon_returns" | 0.008313 | 0.001939 | 4.287955 |
| … | … | … | … | … |
| "C_window" | "cross_sectional_zscore" | 0.003852 | 0.003588 | 1.073382 |
| "D_filter" | "multi_condition_filter" | 0.001201 | 0.001138 | 1.05555 |
| "A_rolling" | "rolling_std_20" | 0.001183 | 0.001193 | 0.9918 |
| "A_rolling" | "rolling_mean_20" | 0.001155 | 0.00119 | 0.970304 |
| "C_window" | "percentile_rank" | 0.00461 | 0.01091 | 0.42252 |
Visualization
# Create visualization
fig = make_subplots(
rows=2,
cols=2,
subplot_titles=[
"Speedup by Category",
"Operation Times (log scale)",
"Speedup Distribution",
"pandas vs Polars Times",
],
specs=[
[{"type": "bar"}, {"type": "bar"}],
[{"type": "histogram"}, {"type": "scatter"}],
],
)
# 1. Speedup by category (bar chart)
cat_data = category_summary.sort("mean_speedup", descending=True)
fig.add_trace(
go.Bar(
x=cat_data["category"].to_list(),
y=cat_data["mean_speedup"].to_list(),
marker_color=COLORS["blue"],
text=[f"{s:.1f}x" for s in cat_data["mean_speedup"].to_list()],
textposition="outside",
),
row=1,
col=1,
)
fig.add_hline(y=1.0, line_dash="dash", line_color="gray", row=1, col=1)
# 2. Operation times (grouped bar)
sorted_results = results_df.sort("speedup", descending=True).head(15)
fig.add_trace(
go.Bar(
name="pandas",
x=sorted_results["operation"].to_list(),
y=sorted_results["pandas_time"].to_list(),
marker_color=COLORS["amber"],
),
row=1,
col=2,
)
fig.add_trace(
go.Bar(
name="Polars",
x=sorted_results["operation"].to_list(),
y=sorted_results["polars_time"].to_list(),
marker_color=COLORS["blue"],
),
row=1,
col=2,
)
# 3. Speedup distribution
fig.add_trace(
go.Histogram(
x=results_df["speedup"].to_list(),
nbinsx=20,
marker_color=COLORS["blue"],
opacity=0.7,
),
row=2,
col=1,
)
fig.add_vline(x=1.0, line_dash="dash", line_color="red", row=2, col=1)
# 4. pandas vs Polars scatter
fig.add_trace(
go.Scatter(
x=results_df["pandas_time"].to_list(),
y=results_df["polars_time"].to_list(),
mode="markers",
marker=dict(color=COLORS["blue"], size=10),
text=results_df["operation"].to_list(),
hovertemplate="%{text}<br>pandas: %{x:.4f}s<br>Polars: %{y:.4f}s<extra></extra>",
),
row=2,
col=2,
)
# Add diagonal (equal performance line)
max_time = max(results_df["pandas_time"].max(), results_df["polars_time"].max())
fig.add_trace(
go.Scatter(
x=[0, max_time],
y=[0, max_time],
mode="lines",
line=dict(dash="dash", color="gray"),
showlegend=False,
),
row=2,
col=2,
)
# Update layout
fig.update_xaxes(title_text="Category", row=1, col=1)
fig.update_yaxes(title_text="Speedup (Polars/pandas)", row=1, col=1)
fig.update_xaxes(title_text="Operation", tickangle=45, row=1, col=2)
fig.update_yaxes(title_text="Time (s)", type="log", row=1, col=2)
fig.update_xaxes(title_text="Speedup", row=2, col=1)
fig.update_yaxes(title_text="Count", row=2, col=1)
fig.update_xaxes(title_text="pandas time (s)", row=2, col=2)
fig.update_yaxes(title_text="Polars time (s)", row=2, col=2)
fig.update_layout(
title_text=f"pandas {PANDAS_VERSION} vs Polars {POLARS_VERSION} Benchmark (Scale: {ACTIVE_SCALE})",
height=800,
showlegend=True,
barmode="group",
)
fig.show()Output
[省略较大 image/png 输出]
Save Results
# Save detailed results
csv_path = RESULTS_DIR / f"pandas_polars_{ACTIVE_SCALE.lower()}.csv"
results_df.write_csv(csv_path)
print(f"Results saved to: {csv_path}")
# Save summary
summary_df = pl.DataFrame(
{
"metric": [
"pandas_version",
"polars_version",
"scale",
"total_rows",
"mean_speedup",
"operations_tested",
"polars_faster_count",
"pandas_faster_count",
"cow_enabled",
"pyarrow_strings",
],
"value": [
PANDAS_VERSION,
POLARS_VERSION,
ACTIVE_SCALE,
str(total_rows),
f"{overall_speedup:.2f}",
str(len(results_df)),
str(operations_faster),
str(operations_slower),
str(COW_ENABLED),
str(PYARROW_STRINGS),
],
}
)
summary_path = RESULTS_DIR / f"pandas_polars_summary_{ACTIVE_SCALE.lower()}.csv"
summary_df.write_csv(summary_path)
print(f"Summary saved to: {summary_path}")Output
Results saved to: 02_financial_data_universe/output/benchmark/pandas_polars_s.csv Summary saved to: 02_financial_data_universe/output/benchmark/pandas_polars_summary_s.csv
Key Takeaways
# Surface this run's category ranking dynamically; the category table and overall
# counts were already displayed in the Summary cell above, so this cell only names
# the top-2 / bottom-2 categories so the takeaways stay in sync with the table.
_ranked = category_summary.sort("mean_speedup", descending=True)
_top2 = _ranked.head(2)["category"].to_list()
_bot2 = _ranked.tail(2)["category"].to_list()
print(f"Scale: {ACTIVE_SCALE} ({total_rows:,} rows)")
print(f"Fastest-on-Polars categories at this scale: {', '.join(_top2)}")
print(f"Smallest-gap (or pandas-faster) categories at this scale: {', '.join(_bot2)}")Output
Scale: S (10,000 rows) Fastest-on-Polars categories at this scale: H_string, E_join Smallest-gap (or pandas-faster) categories at this scale: B_groupby, C_window
What the table above shows
Each row is the mean Polars-over-pandas speedup for the operation category at
the scale chosen for this run (ACTIVE_SCALE). The ordering depends on
scale and the takeaways printed above name this run's top-2 / bottom-2
categories so the prose stays in sync with the actual numbers:
- At S (10K rows) fixed Python overhead compresses the gap. Polars keeps a large, reliable lead on the string, join, and lazy categories, where there is enough work to amortize its setup cost. On the lighter groupby, window, filter, and rolling operations that overhead is a bigger share of the runtime, so Polars' margin is smallest and least stable there and individual categories can land on either side of parity from run to run. The cell above prints this run's top-2 and bottom-2 categories so the prose tracks the actual numbers.
- At L / XL (≥1M rows) Polars' parallelization widens the gap on string, groupby, join, and lazy operations; the narrow-margin categories at S pull further ahead as parallelization amortizes the Python overhead.
Running this notebook at BENCHMARK_SCALE=S and again at a larger scale makes
the trend visible: mean speedup grows with row count as parallelization
amortizes Python overhead.
Decision framework
| Data size | Choice | Reason |
|---|---|---|
| < 100K rows | Either library | pandas stays competitive; Polars adds learning curve |
| 100K – 1M rows | Prefer Polars | Larger gap on join / groupby / string operations |
| > 1M rows | Polars | Parallelization advantage is largest here |
| Visualization | Convert to pandas | matplotlib / seaborn compatibility |
Migration notes
- pandas 2.x → 3.0: free speedup from Copy-on-Write + PyArrow strings.
- pandas 3.0 → Polars: migrate production pipelines processing >100K rows or any pipeline dominated by string / groupby / join operations.
- New projects: start with Polars; convert to pandas at the visualization boundary only.
Book Reference: Section 2.4 (Storing Data) discusses DataFrame engine selection alongside on-disk format and database choices.
print("=" * 70)
print("BENCHMARK COMPLETE")
print("=" * 70)
print(f"pandas {PANDAS_VERSION} vs Polars {POLARS_VERSION}, scale {ACTIVE_SCALE}")
print(f"Results: {csv_path}")Output
====================================================================== BENCHMARK COMPLETE ====================================================================== pandas 2.3.3 vs Polars 1.41.1, scale S Results: 02_financial_data_universe/output/benchmark/pandas_polars_s.csv
