Aggregation and Reshaping - Python

Quote

“Statistics are like bikinis. What they reveal is suggestive, but what they conceal is vital.”

Aaron Levenstein


Set up the shared pandas and polars imports, notebook display hooks, and core parquet datasets used throughout the aggregation and reshaping examples.

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}")
OHLCV: (66355, 12), Dim: (169, 26), Scores: (466, 36)

Grouping and Aggregation

Pandas group keys fall into the index by default

as_index=False vs as_index=True — fundamentally different output Pandas groupby() defaults to as_index=True, which puts group keys into the index. This breaks chaining with .merge() and makes the output unusable with many Pandas operations. Always use as_index=False for pipeline code to get a flat DataFrame.

Polars group_by() always returns a flat DataFrame — no index concept exists.

Keep grouped keys as regular columns

Use df.groupby("col", as_index=False).agg(...) to get a flat DataFrame with group keys as regular columns. This makes the result chainable with .merge(), .sort_values(), and any downstream operation. In Polars, group_by() is always flat — no change needed.

Pandas named aggregation

Pandas named aggregation — .agg(new_name=("column", "func")) — is the cleanest syntax for groupby. It names the output columns explicitly and avoids the confusing MultiIndex column headers that .agg({"col": ["mean", "sum"]}) produces.

Per-Symbol Aggregation

Pandas | Group by symbol to compute mean close, total volume, and trading days

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Aggregates all 66,355 rows by symbol, producing one row per constituent with mean closing price, total share volume, and trading day count, then sorts by avg_close descending to surface the 10 highest-priced stocks.

# Pandas
display(
    ohlcv_pd.groupby("symbol", as_index=False)
    .agg(avg_close=("close", "mean"), total_volume=("volume", "sum"), trading_days=("date", "count"))
    .sort_values("avg_close", ascending=False)
    .head(10)
)
Rendered tabular output preserved below.
symbolavg_closetotal_volumetrading_days
38RMS.PA1761.555748816338621331
3ADYEN.AS1545.9764091104004631331
8ASML.AS671.3489119450707201331
31MC.PA662.4045085578555671331
37RHM.DE544.6615333083597441324
7ARGX.BR413.691961945922441331
34OR.PA377.5443654841153751331
32MUV2.DE374.6599323988029501324
36RACE.MI289.7538234766860261321
6ALV.DE252.19373111019603081324

Polars | Group by symbol to compute mean close, total volume, and trading days

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Replicates the Pandas aggregation using chained Polars expressions in a single .agg() call, rounding avg_close to 2 decimal places and returning the same 10-row flat DataFrame sorted by avg_close descending.

# Polars
display(
    ohlcv_pl.group_by("symbol")
    .agg(
        pl.col("close").mean().round(2).alias("avg_close"),
        pl.col("volume").sum().alias("total_volume"),
        pl.col("date").count().alias("trading_days"),
    )
    .sort("avg_close", descending=True)
    .head(10)
)
Rendered tabular output preserved below.
symbolavg_closetotal_volumetrading_days
strf64i64u32
RMS.PA1761.56816338621331
ADYEN.AS1545.981104004631331
ASML.AS671.359450707201331
MC.PA662.45578555671331
RHM.DE544.663083597441324
ARGX.BR413.69945922441331
OR.PA377.544841153751331
MUV2.DE374.663988029501324
RACE.MI289.754766860261321
ALV.DE252.1911019603081324

Multiple Grouping Columns

Pass a list of column names to groupby() / group_by() to create composite group keys. A common pattern is extracting a date part (year, month, quarter) as a new column and grouping on both symbol and that period — giving per-symbol per-period aggregates without a MultiIndex. Polars expresses the extraction inline with .dt.year() in a .with_columns() step; Pandas extracts to a new column first via pd.to_datetime(...).dt.year.

Composite Group Keys with Date Extraction

Pandas | Group by symbol and year to compute annual average and maximum close price

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Extracts year from the date column into a new ohlcv_pd["year"] column, then groups on ["symbol", "year"] to compute annual average and max close for ASML.AS, showing the last 5 years’ performance sorted ascending.

# Pandas: group by symbol + year
ohlcv_pd["year"] = pd.to_datetime(ohlcv_pd["date"]).dt.year
display(
    ohlcv_pd.groupby(["symbol", "year"], as_index=False)
    .agg(avg_close=("close", "mean"), max_close=("close", "max"))
    .query("symbol == 'ASML.AS'")
    .tail(5)
)
Rendered tabular output preserved below.
symbolyearavg_closemax_close
49ASML.AS2022531.578794701.7
50ASML.AS2023611.328627694.7
51ASML.AS2024799.2621091002.2
52ASML.AS2025724.614118963.4
53ASML.AS20261170.4180001288.4

Polars | Group by symbol and year to compute annual average and maximum close, extracting year inline

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Uses .with_columns(pl.col("date").dt.year()) to extract year without mutating the DataFrame, then groups on ["symbol", "year"] and filters to ASML.AS to show 5 years sorted descending — confirming identical annual statistics as the Pandas result.

# Polars
display(
    ohlcv_pl.with_columns(pl.col("date").dt.year().alias("year"))
    .group_by("symbol", "year")
    .agg(
        pl.col("close").mean().round(2).alias("avg_close"),
        pl.col("close").max().alias("max_close"),
    )
    .filter(pl.col("symbol") == "ASML.AS")
    .sort("year", descending=True)
    .head(5)
)
Rendered tabular output preserved below.
symbolyearavg_closemax_close
stri32f64f64
ASML.AS20261170.421288.4
ASML.AS2025724.61963.4
ASML.AS2024799.261002.2
ASML.AS2023611.33694.7
ASML.AS2022531.58701.7

Multiple Aggregation Functions

Both libraries support computing multiple aggregation functions in a single groupby pass, avoiding the cost of repeated scans. Pandas uses named aggregation syntax — .agg(output_col=("input_col", "func")) — which produces a flat DataFrame with explicitly named columns. Polars uses a list of expressions in .agg(), each chained with .alias(). Named aggregation in Pandas is preferred over the dict-of-lists form .agg({"col": ["mean", "sum"]}), which produces confusing MultiIndex column headers.

Named Aggregation with Multiple Functions

Pandas | Compute mean, std, min, max close and date range per symbol using named aggregation

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Runs six aggregation functions in a single groupby() pass using named aggregation syntax, producing a flat 50-row frame with explicitly named columns — sorted by mean_close descending to rank the 10 highest-priced constituents.

# Pandas: named aggregation
display(
    ohlcv_pd.groupby("symbol", as_index=False).agg(
        mean_close=("close", "mean"),
        std_close=("close", "std"),
        min_close=("close", "min"),
        max_close=("close", "max"),
        first_date=("date", "min"),
        last_date=("date", "max"),
    ).sort_values("mean_close", ascending=False).head(10)
)
Rendered tabular output preserved below.
symbolmean_closestd_closemin_closemax_closefirst_datelast_date
38RMS.PA1761.555748481.321257842.602839.02021-01-042026-03-12
3ADYEN.AS1545.976409417.812759630.802766.02021-01-042026-03-12
8ASML.AS671.348911162.745793397.451288.42021-01-042026-03-12
31MC.PA662.404508103.503255437.55902.02021-01-042026-03-12
37RHM.DE544.661533586.08181977.001988.52021-01-042026-03-12
7ARGX.BR413.691961142.729547208.80803.02021-01-042026-03-12
34OR.PA377.54436537.184584290.10456.92021-01-042026-03-12
32MUV2.DE374.659932123.048080209.15610.62021-01-042026-03-12
36RACE.MI289.75382394.845151154.70487.92021-01-042026-03-12
6ALV.DE252.19373162.466824159.62392.72021-01-042026-03-12

Polars | Compute mean, std, min, max close and date range per symbol using expression list

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Passes six chained expressions to .agg() — each using .alias() to name output columns — returning the same 10-row summary as the Pandas named aggregation with no MultiIndex, sorted by mean_close descending.

# Polars: expressions in .agg()
display(
    ohlcv_pl.group_by("symbol").agg(
        pl.col("close").mean().round(2).alias("mean_close"),
        pl.col("close").std().round(2).alias("std_close"),
        pl.col("close").min().alias("min_close"),
        pl.col("close").max().alias("max_close"),
        pl.col("date").min().alias("first_date"),
        pl.col("date").max().alias("last_date"),
    ).sort("mean_close", descending=True).head(10)
)
Rendered tabular output preserved below.
symbolmean_closestd_closemin_closemax_closefirst_datelast_date
strf64f64f64f64datedate
RMS.PA1761.56481.32842.62839.02021-01-042026-03-12
ADYEN.AS1545.98417.81630.82766.02021-01-042026-03-12
ASML.AS671.35162.75397.451288.42021-01-042026-03-12
MC.PA662.4103.5437.55902.02021-01-042026-03-12
RHM.DE544.66586.0877.01988.52021-01-042026-03-12
ARGX.BR413.69142.73208.8803.02021-01-042026-03-12
OR.PA377.5437.18290.1456.92021-01-042026-03-12
MUV2.DE374.66123.05209.15610.62021-01-042026-03-12
RACE.MI289.7594.85154.7487.92021-01-042026-03-12
ALV.DE252.1962.47159.62392.72021-01-042026-03-12

Transform: Same-Length Output

groupby().transform() broadcasts a group-level statistic back to every row in the original DataFrame without collapsing it. Use this when you need both the raw value and its group context on the same row — e.g., “what is ASML’s close price vs its all-time group mean?“. The output is always the same length as the input. Pandas uses .transform("mean") for this; Polars uses .mean().over("group_col") (covered in detail in Part 2).

Same-Length Group Broadcast

Pandas | Broadcast symbol mean close back to each row alongside deviation percentage

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Filters to ASML.AS, adds group_avg via .groupby("symbol")["close"].transform("mean"), then computes vs_avg as the percentage deviation of each day’s close from the all-time mean — returning the last 10 rows at full DataFrame length without collapsing rows.

# Pandas: group mean alongside each row
asml_pd = ohlcv_pd[ohlcv_pd["symbol"] == "ASML.AS"].copy()
asml_pd["group_avg"] = asml_pd.groupby("symbol")["close"].transform("mean")
asml_pd["vs_avg"] = ((asml_pd["close"] - asml_pd["group_avg"]) / asml_pd["group_avg"] * 100).round(2)
display(asml_pd[["symbol", "date", "close", "group_avg", "vs_avg"]].tail(10))
Rendered tabular output preserved below.
symboldateclosegroup_avgvs_avg
11955ASML.AS2026-02-271233.4671.34891183.72
11956ASML.AS2026-03-021210.4671.34891180.29
11957ASML.AS2026-03-031161.8671.34891173.05
11958ASML.AS2026-03-041199.8671.34891178.71
11959ASML.AS2026-03-051186.0671.34891176.66
11960ASML.AS2026-03-061147.0671.34891170.85
11961ASML.AS2026-03-091147.6671.34891170.94
11962ASML.AS2026-03-101200.0671.34891178.74
11963ASML.AS2026-03-111198.8671.34891178.57
11964ASML.AS2026-03-121190.8671.34891177.37

Polars equivalent: .over() window expression

Polars replaces .transform() with .mean().over("symbol") inside .with_columns(). The result is identical — one group mean value broadcast across all rows — but Polars computes it as a window expression without mutating the DataFrame. See the full .over() examples in Part 2 — Window Functions below.

Sector Analysis with Scores

Group the scores dimension table by sector to compute portfolio-level metrics: count of constituents, average composite and momentum scores, and total index weight per sector. This is a typical index analytics query — the result is a small 10-row frame (one row per sector) regardless of how many stocks are in the dataset.

Portfolio-Level Sector Metrics

Pandas | Aggregate scores by sector to compute constituent count, average scores, and total weight

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Groups the 466-row scores table by sector, computing stock count, mean composite and momentum scores, and summed index weight per sector, returning a 10-row frame sorted by avg_composite descending.

# Pandas: sector aggregation
display(
    scores_pd.groupby("sector", as_index=False).agg(
        stocks=("symbol", "count"),
        avg_composite=("composite_score", "mean"),
        avg_momentum=("momentum_score", "mean"),
        total_weight=("index_weight", "sum"),
    ).sort_values("avg_composite", ascending=False)
)
Rendered tabular output preserved below.
sectorstocksavg_compositeavg_momentumtotal_weight
8Technology750.154008-0.1970892.147753
4Energy340.1056590.5027551.199656
7Industrials660.0874220.3034631.315854
1Communication Services360.049356-0.1824661.021353
0Basic Materials180.0430090.4474550.185352
6Healthcare450.019073-0.2096650.543554
3Consumer Defensive36-0.0753100.2477090.473144
5Financial Services96-0.089991-0.0564561.468250
9Utilities6-0.0993770.7253500.133743
2Consumer Cyclical54-0.137446-0.4132201.423495

Polars | Aggregate scores by sector to compute constituent count, average scores, and total weight

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Replicates the Pandas sector aggregation using .count(), .mean().round(4), and .sum().round(4) expressions in a single .agg() call, producing the same 10-row sector summary sorted by avg_composite descending.

# Polars: same analysis
display(
    scores_pl.group_by("sector").agg(
        pl.col("symbol").count().alias("stocks"),
        pl.col("composite_score").mean().round(4).alias("avg_composite"),
        pl.col("momentum_score").mean().round(4).alias("avg_momentum"),
        pl.col("index_weight").sum().round(4).alias("total_weight"),
    ).sort("avg_composite", descending=True)
)
Rendered tabular output preserved below.
sectorstocksavg_compositeavg_momentumtotal_weight
stru32f64f64f64
Technology750.154-0.19712.1478
Energy340.10570.50281.1997
Industrials660.08740.30351.3159
Communication Services360.0494-0.18251.0214
Basic Materials180.0430.44750.1854
Healthcare450.0191-0.20970.5436
Consumer Defensive36-0.07530.24770.4731
Financial Services96-0.09-0.05651.4682
Utilities6-0.09940.72530.1337
Consumer Cyclical54-0.1374-0.41321.4235

Group By + Sort Pattern

To retrieve the top-N rows per group (e.g., top 3 stocks per sector by rank), pre-sort the DataFrame and then call .group_by().head(n). This is more efficient than filtering with rank().over() and avoids a second sort pass. Polars .group_by() is unordered by default, so the pre-sort is essential — it determines which rows each group “sees first”.

Pandas equivalent: sort + groupby + head

Pandas has no direct .group_by().head() method. The equivalent pattern is:

scores_pd.sort_values("composite_rank").groupby("sector", as_index=False).head(3)

This works but returns rows in the original sorted order across all groups, not grouped. Chain .sort_values(["sector", "composite_rank"]) after to match the Polars output layout.

Top-N Rows Per Group

Polars | Retrieve top 3 stocks per sector by composite rank using group_by().head()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Pre-sorts the scores table by composite_rank ascending, then calls .group_by("sector").head(3) to capture the 3 highest-ranked stocks per sector — producing a 30-row result across all 10 sectors, re-sorted by sector and rank.

# Top 3 stocks per sector by composite score (Polars)
display(
    scores_pl
    .sort("composite_rank")
    .group_by("sector")
    .head(3)
    .select("sector", "symbol", "composite_score", "composite_rank")
    .sort("sector", "composite_rank")
)
Rendered tabular output preserved below.
sectorsymbolcomposite_scorecomposite_rank
strstrf64i64
Basic Materials4063.T0.3440087
Basic Materials4063.T0.2448411
Basic Materials4063.T0.24572812
Communication ServicesDTE.DE0.5150052
Communication ServicesDTE.DE0.5214422
Communication ServicesDTE.DE0.4870493
Consumer CyclicalVOW.DE0.575612
Consumer CyclicalVOW.DE0.4633233
Consumer Cyclical7203.T0.4299363
Consumer DefensiveABI.BR0.420944
Consumer DefensiveABI.BR0.385215
Consumer DefensiveABI.BR0.4068735
EnergyDVN0.6655071
EnergyVLO0.490092
EnergyWDS.AX0.4906542
Financial ServicesBNP.PA0.6795991
Financial ServicesBNP.PA0.6639711
Financial ServicesBNP.PA0.6839471
Healthcare2269.HK0.3574995
Healthcare4568.T0.3558436
Healthcare4568.T0.3774556
Industrials8001.T0.4784441
Industrials8031.T0.489322
Industrials8001.T0.466163
Technology6981.T0.4946021
Technology6981.T0.5465811
TechnologyMU0.9258381
UtilitiesENEL.MI0.03933725
UtilitiesENEL.MI0.01866826
UtilitiesENEL.MI0.00243527

Grouping Crosswalk

OperationPandasPolars
Group by.groupby(col).group_by(col)
Aggregate.agg(name=(col, func)).agg(pl.col(col).func().alias(name))
Transform.groupby().transform().over() window expression
Multiple aggsdict of (col, func)List of expressions
Reset indexas_index=FalseNot needed (no index)
Filter groups.filter(func).filter() after group_by

Window Functions

Window Transform

Window functions compute per-row values that depend on a partition (group) of the data — without collapsing rows. They are the DataFrame equivalent of SQL OVER(PARTITION BY col ORDER BY ...). Common uses: broadcasting a group mean or sum back to each row, computing within-group ranks, and rolling statistics scoped to a symbol’s own history.

Window functions like PARTITION BY and ROWS BETWEEN appear across SQL and DataFrame APIs. The SQL Server gold layer in gold-transforms applies the same ranking and running-total logic, and bq-advanced covers BigQuery window functions for identical analytical needs.

Broadcasting Group Statistics

Pandas | Window transform (groupby().transform())

.groupby().transform(func) applies func to each group and broadcasts the result back to the full-length DataFrame. It is the Pandas idiom for “add a group-level column without collapsing rows”. The function receives each group’s Series and must return a same-length Series. Common functions: "mean", "sum", "rank", or a custom lambda.

Filters to ASML.AS, broadcasts the all-time mean close back to each row as avg_close using .transform("mean"), then adds a rank column via .rank(ascending=False) — showing the last 10 rows with both raw close and its ordinal position in the full 1,331-row ASML history.

asml=ohlcv_pd[ohlcv_pd["symbol"]=="ASML.AS"].copy()
asml["avg_close"]=asml.groupby("symbol")["close"].transform("mean")
asml["rank"]=asml["close"].rank(ascending=False)
display(asml[["date","close","avg_close","rank"]].tail(10))
Rendered tabular output preserved below.
datecloseavg_closerank
119552026-02-271233.4671.3489117.0
119562026-03-021210.4671.34891112.0
119572026-03-031161.8671.34891133.0
119582026-03-041199.8671.34891116.0
119592026-03-051186.0671.34891127.0
119602026-03-061147.0671.34891138.0
119612026-03-091147.6671.34891137.0
119622026-03-101200.0671.34891115.0
119632026-03-111198.8671.34891118.0
119642026-03-121190.8671.34891124.0

Polars | Window transform (.over())

.expr.over("group_col") in Polars computes the expression within each partition defined by group_col and broadcasts the result back to each row — the exact equivalent of Pandas .transform(). Unlike .transform(), .over() can be chained with any expression (.mean(), .rank(), .cum_sum()) inside a single .with_columns() call without multiple passes. Multiple .over() expressions in one .with_columns() are computed in parallel.

Replicates the Pandas transform by computing .mean().over("symbol") and .rank(descending=True).over("symbol") in a single .with_columns() call — showing that multiple .over() expressions are evaluated in parallel, producing identical last-10-row values as the Pandas result.

display(
    ohlcv_pl.filter(pl.col("symbol")=="ASML.AS")
    .with_columns(
        pl.col("close").mean().over("symbol").round(2).alias("avg_close"),
        pl.col("close").rank(descending=True).over("symbol").alias("rank"),
    ).select("date","close","avg_close","rank").tail(10)
)
Rendered tabular output preserved below.
datecloseavg_closerank
datef64f64f64
2026-02-271233.4671.357.0
2026-03-021210.4671.3512.0
2026-03-031161.8671.3533.0
2026-03-041199.8671.3516.0
2026-03-051186.0671.3527.0
2026-03-061147.0671.3538.0
2026-03-091147.6671.3537.0
2026-03-101200.0671.3515.0
2026-03-111198.8671.3518.0
2026-03-121190.8671.3524.0

Rolling Windows

A rolling window aggregation computes a statistic over the last N consecutive rows per row — the classic moving average. Pandas uses .rolling(n).mean() / .rolling(n).max() on a Series. Polars uses .rolling_mean(window_size=n) / .rolling_max(window_size=n) as expressions in .with_columns(). Both produce null / NaN for the first n-1 rows where the window is incomplete.

Positional rolling-window arguments no longer survive Polars 1.x

In Polars 0.x, rolling_mean(7) accepted the window size as a positional argument. Polars 1.x requires the keyword argument: rolling_mean(window_size=7). The positional form raises a DeprecationWarning in 0.x and a TypeError in 1.x. Always use window_size= explicitly to future-proof your code.

Always pass the rolling window size by keyword

Write rolling_mean(window_size=7), rolling_max(window_size=30), and the equivalent keyword form for every rolling expression. That keeps the code explicit and avoids version-sensitive breakage.

Moving Average and Rolling Maximum

Pandas | Compute 7-day SMA and 30-day rolling max for ASML.AS using rolling()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Filters to ASML.AS sorted by date, then uses .assign() with .rolling(7).mean() and .rolling(30).max() to append sma_7 and rolling_max — displaying the last 10 rows where the 30-day high is uniformly 1,288.4.

asml_s=ohlcv_pd[ohlcv_pd["symbol"]=="ASML.AS"].sort_values("date")
display(asml_s.assign(
    sma_7=asml_s["close"].rolling(7).mean(),
    rolling_max=asml_s["close"].rolling(30).max(),
)[["date","close","sma_7","rolling_max"]].tail(10))
Rendered tabular output preserved below.
dateclosesma_7rolling_max
119552026-02-271233.41251.5142861288.4
119562026-03-021210.41247.5428571288.4
119572026-03-031161.81234.1428571288.4
119582026-03-041199.81227.0857141288.4
119592026-03-051186.01216.0285711288.4
119602026-03-061147.01195.8285711288.4
119612026-03-091147.61183.7142861288.4
119622026-03-101200.01178.9428571288.4
119632026-03-111198.81177.2857141288.4
119642026-03-121190.81181.4285711288.4

Polars | Compute 7-day SMA and 30-day rolling max for ASML.AS using rolling_mean() and rolling_max()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Replicates the Pandas rolling windows using rolling_mean(window_size=7) and rolling_max(window_size=30) as expressions in a single .with_columns() call, producing the same last-10-row result with identical SMA and rolling high values.

display(
    ohlcv_pl.filter(pl.col("symbol")=="ASML.AS").sort("date")
    .with_columns(
        pl.col("close").rolling_mean(window_size=7).alias("sma_7"),
        pl.col("close").rolling_max(window_size=30).alias("rolling_max"),
    ).select("date","close","sma_7","rolling_max").tail(10)
)
Rendered tabular output preserved below.
dateclosesma_7rolling_max
datef64f64f64
2026-02-271233.41251.5142861288.4
2026-03-021210.41247.5428571288.4
2026-03-031161.81234.1428571288.4
2026-03-041199.81227.0857141288.4
2026-03-051186.01216.0285711288.4
2026-03-061147.01195.8285711288.4
2026-03-091147.61183.7142861288.4
2026-03-101200.01178.9428571288.4
2026-03-111198.81177.2857141288.4
2026-03-121190.81181.4285711288.4

Cumulative

Cumulative (running) aggregations compute a running total, maximum, or count from the first row to the current row. Use cum_sum on volume to track total shares traded to date; use cum_max on price to track the running all-time high. Both operations produce the same length as the input. Ensure the DataFrame is sorted by date before applying cumulative operations — both Pandas and Polars process rows in storage order.

Running Totals and All-Time Highs

Pandas | Compute cumulative volume and running close high for ASML.AS using cumsum() and cummax()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Filters to ASML.AS sorted by date, appending cum_vol (running total shares traded) and run_high (all-time closing high) via .assign() — showing the last 10 rows where cumulative volume approaches 945 million shares.

# Pandas: cumulative volume and running high
asml_pd_c = ohlcv_pd[ohlcv_pd["symbol"] == "ASML.AS"].sort_values("date")
display(
    asml_pd_c.assign(
        cum_vol=asml_pd_c["volume"].cumsum(),
        run_high=asml_pd_c["close"].cummax(),
    )[["date", "close", "volume", "cum_vol", "run_high"]].tail(10)
)
      date  close  volume   cum_vol  run_high
2026-02-27 1233.4 1010698 938726541    1288.4
2026-03-02 1210.4  871267 939597808    1288.4
2026-03-03 1161.8  941945 940539753    1288.4
2026-03-04 1199.8  714587 941254340    1288.4
2026-03-05 1186.0  778081 942032421    1288.4
2026-03-06 1147.0  857271 942889692    1288.4
2026-03-09 1147.6  689086 943578778    1288.4
2026-03-10 1200.0  800815 944379593    1288.4
2026-03-11 1198.8  562904 944942497    1288.4
2026-03-12 1190.8  128223 945070720    1288.4

Polars | Compute cumulative volume and running close high for ASML.AS using cum_sum() and cum_max()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Replicates the Pandas cumulative operations using pl.col("volume").cum_sum() and pl.col("close").cum_max() in a single .with_columns(), confirming identical running totals and all-time highs in the last 10 rows.

# Polars: cum_sum / cum_max
display(
    ohlcv_pl.filter(pl.col("symbol")=="ASML.AS").sort("date")
    .with_columns(
        pl.col("volume").cum_sum().alias("cum_vol"),
        pl.col("close").cum_max().alias("run_high"),
    ).select("date","close","volume","cum_vol","run_high").tail(10)
)
Rendered tabular output preserved below.
dateclosevolumecum_volrun_high
datef64i64i64f64
2026-02-271233.410106989387265411288.4
2026-03-021210.48712679395978081288.4
2026-03-031161.89419459405397531288.4
2026-03-041199.87145879412543401288.4
2026-03-051186.07780819420324211288.4
2026-03-061147.08572719428896921288.4
2026-03-091147.66890869435787781288.4
2026-03-101200.08008159443795931288.4
2026-03-111198.85629049449424971288.4
2026-03-121190.81282239450707201288.4

Rank Within Groups

Within-group ranking assigns each row a rank number based on its value within its group, without collapsing the DataFrame. Use this to find the top-N stocks per sector, or to flag outliers within a group. Polars uses .rank(descending=True).over("sector") inside .with_columns(). Pandas uses .groupby()["col"].rank(ascending=False), which also broadcasts the rank back to every row.

Within-Group Percentile Ranking

Pandas | Rank each stock within its sector by composite score using groupby().rank()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Assigns each stock a within-sector rank based on composite_score descending, broadcasting the rank back to every row using .groupby("sector")["composite_score"].rank(ascending=False) — showing the top 15 rows sorted by sector and rank.

# Pandas: rank within sector
display(
    scores_pd.assign(
        sector_rank=scores_pd.groupby("sector")["composite_score"].rank(ascending=False)
    )[["sector", "symbol", "composite_score", "sector_rank"]]
    .sort_values(["sector", "sector_rank"])
    .head(15)
)
         sector symbol  composite_score  sector_rank
Basic Materials 4063.T         0.344008          1.0
Basic Materials 4063.T         0.245728          2.0
Basic Materials 4063.T         0.244840          3.0
Basic Materials  AI.PA         0.097187          4.0
Basic Materials  AI.PA         0.088726          5.0
Basic Materials    LIN         0.072093          6.0
Basic Materials  AI.PA         0.063075          7.0
Basic Materials    LIN         0.056958          8.0
Basic Materials RIO.AX         0.044820          9.0
Basic Materials BHP.AX         0.026917         10.0
Basic Materials    LIN         0.023257         11.0
Basic Materials RIO.AX        -0.017276         12.0
Basic Materials BHP.AX        -0.032021         13.0
Basic Materials RIO.AX        -0.068535         14.0
Basic Materials BAS.DE        -0.084199         15.0

Polars | Rank each stock within its sector by composite score using rank().over()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Computes the within-sector rank in a single .with_columns() call using .rank(descending=True).over("sector") — returning the same 15-row result sorted by sector and rank, with all sectors’ rankings computed in parallel.

# Polars: .rank().over()
display(
    scores_pl.with_columns(
        pl.col("composite_score").rank(descending=True).over("sector").alias("sector_rank")
    ).select("sector","symbol","composite_score","sector_rank")
    .sort("sector","sector_rank").head(15)
)
Rendered tabular output preserved below.
sectorsymbolcomposite_scoresector_rank
strstrf64f64
Basic Materials4063.T0.3440081.0
Basic Materials4063.T0.2457282.0
Basic Materials4063.T0.244843.0
Basic MaterialsAI.PA0.0971874.0
Basic MaterialsAI.PA0.0887265.0
Basic MaterialsLIN0.0720936.0
Basic MaterialsAI.PA0.0630757.0
Basic MaterialsLIN0.0569588.0
Basic MaterialsRIO.AX0.044829.0
Basic MaterialsBHP.AX0.02691710.0
Basic MaterialsLIN0.02325711.0
Basic MaterialsRIO.AX-0.01727612.0
Basic MaterialsBHP.AX-0.03202113.0
Basic MaterialsRIO.AX-0.06853514.0
Basic MaterialsBAS.DE-0.08419915.0

Lead / Lag

Shift (lag/lead) accesses the value from a previous (shift(1)) or future (shift(-1)) row. Use lag to compute daily returns (close / prev_close - 1) or to detect price-direction changes. The first row of a lag series and the last row of a lead series are NaN (Pandas) or null (Polars). Both use .shift(n) with the same sign convention: positive = look back, negative = look forward.

Shift for Lag and Lead Values

Pandas | Compute previous-day and next-day close for ASML.AS using shift()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Filters to ASML.AS sorted by date, then uses .assign() to add prev_close (shift(1)) and next_close (shift(-1)) — showing the last 10 rows where the final row’s next_close is NaN because no future row exists.

# Pandas: shift
asml_pd_s = ohlcv_pd[ohlcv_pd["symbol"] == "ASML.AS"].sort_values("date")
display(
    asml_pd_s.assign(
        prev_close=asml_pd_s["close"].shift(1),
        next_close=asml_pd_s["close"].shift(-1),
    )[["date", "close", "prev_close", "next_close"]].tail(10)
)
      date  close  prev_close  next_close
2026-02-27 1233.4      1232.4      1210.4
2026-03-02 1210.4      1233.4      1161.8
2026-03-03 1161.8      1210.4      1199.8
2026-03-04 1199.8      1161.8      1186.0
2026-03-05 1186.0      1199.8      1147.0
2026-03-06 1147.0      1186.0      1147.6
2026-03-09 1147.6      1147.0      1200.0
2026-03-10 1200.0      1147.6      1198.8
2026-03-11 1198.8      1200.0      1190.8
2026-03-12 1190.8      1198.8         NaN

Polars | Compute previous-day and next-day close for ASML.AS using shift()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Replicates the Pandas shift using pl.col("close").shift(1) and pl.col("close").shift(-1) in a single .with_columns() call — producing the same last-10-row result with null (instead of NaN) for the final lead value.

# Polars: shift
display(
    ohlcv_pl.filter(pl.col("symbol")=="ASML.AS").sort("date")
    .with_columns(
        pl.col("close").shift(1).alias("prev_close"),
        pl.col("close").shift(-1).alias("next_close"),
    ).select("date","close","prev_close","next_close").tail(10)
)
Rendered tabular output preserved below.
datecloseprev_closenext_close
datef64f64f64
2026-02-271233.41232.41210.4
2026-03-021210.41233.41161.8
2026-03-031161.81210.41199.8
2026-03-041199.81161.81186.0
2026-03-051186.01199.81147.0
2026-03-061147.01186.01147.6
2026-03-091147.61147.01200.0
2026-03-101200.01147.61198.8
2026-03-111198.81200.01190.8
2026-03-121190.81198.8null

Window Function Crosswalk

OpPandasPolars
Window avggroupby().transform().mean().over()
Rolling.rolling(n).mean().rolling_mean(window_size=n)
Cumulative.cumsum().cum_sum()
Rank.rank().rank().over()
Shift.shift(n).shift(n)

Combining DataFrames

Combining DataFrames covers joins (key-based row matching), concatenation (stacking frames), and set-based filters (anti/semi). Both Pandas and Polars use SQL-style join semantics: inner, left, right, full outer, anti, semi, cross. The key API difference is that Pandas uses .merge() / pd.merge() while Polars uses .join().

Additional Dataset Loading

Python | Load USA OHLCV, signals, trading calendar, and index performance datasets

Load the additional parquet inputs once so the join and concat examples can reuse stable usa_*, signals_*, cal_*, and perf_* frames without redefining paths in every subsection.

Populate the secondary pandas and polars DataFrames used in the combination examples, including the USA OHLCV set, daily signals, trading calendar, and performance tables.

# Additional datasets
usa_pd = pd.read_parquet(DATA / "stoxxusa50_ohlcv.parquet")
usa_pl = pl.read_parquet(DATA / "stoxxusa50_ohlcv.parquet")
signals_pd = pd.read_parquet(DATA / "signals_daily.parquet")
signals_pl = pl.read_parquet(DATA / "signals_daily.parquet")
cal_pd = pd.read_parquet(DATA / "trading_calendar.parquet")
cal_pl = pl.read_parquet(DATA / "trading_calendar.parquet")
perf_pd = pd.read_parquet(DATA / "index_performance.parquet")
perf_pl = pl.read_parquet(DATA / "index_performance.parquet")
 
print(f"USA OHLCV: pandas={usa_pd.shape}, polars={usa_pl.shape}")
print(f"Signals: pandas={signals_pd.shape}, polars={signals_pl.shape}")
print(f"Trading calendar: pandas={cal_pd.shape}, polars={cal_pl.shape}")
print(f"Index performance: pandas={perf_pd.shape}, polars={perf_pl.shape}")
USA OHLCV: pandas=(65100, 12), polars=(65100, 12)
Signals: pandas=(466, 19), polars=(466, 19)
Trading calendar: pandas=(29335, 11), polars=(29335, 11)
Index performance: pandas=(5281, 15), polars=(5281, 15)

Inner Join

Silent row explosion on many-to-many joins

If both DataFrames have duplicate keys and you don’t set validate=, merge() produces a Cartesian product for those keys — your 66K row DataFrame can become millions of rows with no error or warning. Always add validate='many_to_one' or validate='one_to_one' to catch unexpected duplicates.

# Safe merge — raises MergeError if key relationship is violated
result = ohlcv_pd.merge(dim_pd, on="symbol", validate="many_to_one")

Use validate= on every merge to detect unexpected duplicates

Pass validate='many_to_one' or validate='one_to_one' to pd.merge() / .merge(). A MergeError is raised immediately if the key cardinality violates the constraint, preventing silent row explosions. In Polars, use .join_where() or assert result.shape[0] == left.shape[0] after the join when the relationship should be many-to-one.

Join safety is only one half of the problem. The other is default join behavior and post-join ergonomics, which still deserve an explicit check even when cardinality is valid.

Default join behavior still hides dropped rows and suffix surprises

  • Pandas merge() defaults to how='inner' — rows without matches are silently dropped
  • Polars join() defaults to how='inner' too, but uses different suffix behavior: Pandas appends _x/_y, Polars appends _right

Declare the join shape and audit the result immediately

Always pass how='inner', how='left', etc. explicitly — never rely on defaults. After any join, assert len(result) == expected to catch silent row drops or explosions. In Polars, use suffix="_right" awareness or rename() the conflicting column before joining to avoid ambiguous column names.

Key-Based Row Matching

Pandas | Inner join OHLCV to dimension table on symbol, adding short_name and sector columns

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Merges the 66,355-row OHLCV frame with the 169-row dimension table on symbol using how="inner", enriching every price row with short_name and sector — confirming all 66,355 rows are retained because every OHLCV symbol has a dimension entry.

# Pandas
result_pd = ohlcv_pd.merge(dim_pd[["symbol", "short_name", "sector"]], on="symbol", how="inner")
print(f"Pandas inner: {len(result_pd)}")
display(result_pd[["symbol", "short_name", "date", "close", "sector"]].head(5))
Pandas inner: 66355
symbolshort_namedateclosesector
0ABI.BRAB INBEV2021-01-0457.21Consumer Defensive
1ABI.BRAB INBEV2021-01-0557.18Consumer Defensive
2ABI.BRAB INBEV2021-01-0658.77Consumer Defensive
3ABI.BRAB INBEV2021-01-0758.40Consumer Defensive
4ABI.BRAB INBEV2021-01-0857.86Consumer Defensive

Polars | Inner join OHLCV to dimension table on symbol, adding short_name and sector columns

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Replicates the Pandas inner join using .join(dim_pl.select(...), on="symbol", how="inner"), confirming 66,355 rows retained — Polars uses _right instead of _x/_y for duplicate column name collisions.

# Polars
result_pl = ohlcv_pl.join(dim_pl.select("symbol", "short_name", "sector"), on="symbol", how="inner")
print(f"Polars inner: {result_pl.height}")
display(result_pl.select("symbol", "short_name", "date", "close", "sector").head(5))
Polars inner: 66355
symbolshort_namedateclosesector
strstrdatef64str
ABI.BRAB INBEV2021-01-0457.21Consumer Defensive
ABI.BRAB INBEV2021-01-0557.18Consumer Defensive
ABI.BRAB INBEV2021-01-0658.77Consumer Defensive
ABI.BRAB INBEV2021-01-0758.4Consumer Defensive
ABI.BRAB INBEV2021-01-0857.86Consumer Defensive

Left Join

Duplicate keys on the right side multiply left-join rows

Left join with duplicate keys silently multiplies rows This left join produces more rows than the left DataFrame because scores_pd has multiple rows per symbol (one per date). The output has len(ohlcv) × scores_per_symbol rows — a classic accidental many-to-many. Always check len(result) after a join.

Collapse the right side to one row per key before joining

Before a left join, deduplicate the right DataFrame to one row per key: scores_pd.drop_duplicates("symbol"). Or keep only the latest score with .sort_values("date").groupby("symbol").last().reset_index(). Then assert len(result) == len(ohlcv_pd) to confirm no row multiplication occurred.

Preserving All Left-Side Rows

Pandas | Left join OHLCV to scores on symbol, attaching composite score to each price row

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Joins the 66,355-row OHLCV frame to the scores table on symbol using how="left", demonstrating the many-to-many row explosion where ABI.BR appears three times (one per scoring date) — showing the first 5 rows to expose the duplication.

# Pandas
result_pd = ohlcv_pd.merge(scores_pd[["symbol", "composite_score", "composite_rank"]], on="symbol", how="left")
display(result_pd[["symbol", "date", "close", "composite_score"]].head(5))
Rendered tabular output preserved below.
symboldateclosecomposite_score
0ABI.BR2021-01-0457.210.406873
1ABI.BR2021-01-0457.210.420940
2ABI.BR2021-01-0457.210.385210
3ABI.BR2021-01-0557.180.406873
4ABI.BR2021-01-0557.180.420940

Polars | Left join OHLCV to scores on symbol, attaching composite score to each price row

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Replicates the Pandas left join using .join(scores_pl.select(...), on="symbol", how="left"), producing the same many-to-many expansion — confirming identical composite score values as the Pandas result in the first 5 rows.

# Polars
result_pl = ohlcv_pl.join(scores_pl.select("symbol", "composite_score", "composite_rank"), on="symbol", how="left")
display(result_pl.select("symbol", "date", "close", "composite_score").head(5))
Rendered tabular output preserved below.
symboldateclosecomposite_score
strdatef64f64
ABI.BR2021-01-0457.210.406873
ABI.BR2021-01-0457.210.42094
ABI.BR2021-01-0457.210.38521
ABI.BR2021-01-0557.180.406873
ABI.BR2021-01-0557.180.42094

Anti Join

An anti join returns rows from the left DataFrame that have no match in the right DataFrame. Use it to find coverage gaps: symbols in OHLCV that are not yet in the scores table, or securities missing from a dimension file. It is more readable than a .merge() followed by df[df["col"].isna()].

No native Pandas anti join

Pandas has no how='anti' parameter. The equivalent is a left join with an indicator column:

result = ohlcv_pd.merge(scores_pd[["symbol"]].drop_duplicates(),
                         on="symbol", how="left", indicator=True)
anti = result[result["_merge"] == "left_only"].drop(columns="_merge")

Or use ~df["symbol"].isin(other["symbol"]) for simple key-exclusion filters.

Identifying Unmatched Rows

Polars | Find symbols in OHLCV that have no match in the scores table using anti join

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Extracts unique symbols from both OHLCV and scores, then applies how="anti" to return only OHLCV symbols absent from scores — confirming 0 unmatched symbols, meaning full OHLCV coverage in the scores table.

# Polars: symbols in OHLCV not in scores
result = ohlcv_pl.select("symbol").unique().join(scores_pl.select("symbol").unique(), on="symbol", how="anti")
print(f"Symbols without scores: {result.height}")
display(result)
Symbols without scores: 0
symbol
str

Semi Join

A semi join returns rows from the left DataFrame that have at least one match in the right DataFrame — but it does not add any columns from the right side. Use it to filter a large fact table (OHLCV) down to only the symbols that exist in a dimension or scoring table, without risk of row duplication.

No native Pandas semi join

Pandas has no how='semi' parameter. The equivalent filter is:

semi = ohlcv_pd[ohlcv_pd["symbol"].isin(scores_pd["symbol"].unique())]

This is efficient for simple key membership tests but does not generalize to multi-column join keys.

Filtering by Key Membership

Polars | Filter OHLCV to keep only symbols present in the scores table using semi join

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Joins the 66,355-row OHLCV to the unique scores symbols using how="semi", returning all 66,355 rows — confirming every OHLCV symbol has a corresponding scores entry without adding any columns from the right side.

result = ohlcv_pl.join(scores_pl.select("symbol").unique(), on="symbol", how="semi")
print(f"OHLCV rows with scores: {result.height} (of {ohlcv_pl.height})")
OHLCV rows with scores: 66355 (of 66355)

Cross Join

A cross join produces the Cartesian product of two DataFrames: every row in the left is paired with every row in the right. Result row count = len(left) × len(right). Use this to generate all (symbol, date) combinations for a universe/calendar scaffold, then left-join actual prices onto it to expose gaps.

Cross joins scale quadratically

Joining two tables of 50 and 1331 rows produces 66,550 rows. Joining OHLCV (66K rows) with itself produces 4.4 billion rows. Always apply .select() to the smallest possible subset before a cross join.

Bound the Cartesian product before you execute it

Filter one side first, keep only the columns you actually need, and compute the expected left_rows * right_rows output size before running how="cross". If the multiplication is not acceptable on paper, it is not safe in code.

Cartesian Product Generation

Polars | Generate all combinations of two symbols and two dates using cross join

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Joins a 2-row symbols DataFrame to a 2-row dates DataFrame using how="cross", producing all 4 (symbol, date) combinations — demonstrating the Cartesian product that scales to millions of rows on larger inputs.

syms = pl.DataFrame({"symbol": ["ASML.AS", "MC.PA"]})
dts = pl.DataFrame({"date": ["2026-03-01", "2026-03-02"]})
display(syms.join(dts, how="cross"))
Rendered tabular output preserved below.
symboldate
strstr
ASML.AS2026-03-01
ASML.AS2026-03-02
MC.PA2026-03-01
MC.PA2026-03-02

Vertical Concat

Vertical concatenation stacks DataFrames on top of each other (adds rows). Both DataFrames must have compatible schemas — same column names and compatible types. Use this to combine data from multiple time periods, markets, or API pages into a single frame. Polars uses pl.concat([df1, df2]); Pandas uses pd.concat([df1, df2], ignore_index=True). Always pass ignore_index=True in Pandas to reset the row index after concat — without it, duplicate index values are preserved, which breaks many downstream operations.

Stacking Frames Row-Wise

Pandas | Stack Euro Stoxx 50 and US 50 OHLCV head rows vertically using pd.concat()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Concatenates the first 3 rows of ohlcv_pd (ABI.BR) and usa_pd (AAPL) using pd.concat([...], ignore_index=True), producing a 6-row frame with a clean integer index reset — one frame stacked on top of the other.

# Pandas
combined_pd = pd.concat([ohlcv_pd.head(3), usa_pd.head(3)], ignore_index=True)
display(combined_pd[["symbol", "date", "close"]])
Rendered tabular output preserved below.
symboldateclose
0ABI.BR2021-01-0457.21
1ABI.BR2021-01-0557.18
2ABI.BR2021-01-0658.77
3AAPL2021-01-04129.41
4AAPL2021-01-05131.01
5AAPL2021-01-06126.60

Polars | Stack Euro Stoxx 50 and US 50 OHLCV head rows vertically using pl.concat()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Replicates the Pandas vertical concat using pl.concat([ohlcv_pl.head(3), usa_pl.head(3)]), returning the same 6-row result — no ignore_index parameter needed since Polars has no row index concept.

# Polars
combined_pl = pl.concat([ohlcv_pl.head(3), usa_pl.head(3)])
display(combined_pl.select("symbol", "date", "close"))
Rendered tabular output preserved below.
symboldateclose
strdatef64
ABI.BR2021-01-0457.21
ABI.BR2021-01-0557.18
ABI.BR2021-01-0658.77
AAPL2021-01-04129.41
AAPL2021-01-05131.01
AAPL2021-01-06126.6

Horizontal Concat

Horizontal concatenation adds columns side by side. Both DataFrames must have the same number of rows and no overlapping column names. Use this to attach a computed Series or a separate feature frame to an existing DataFrame. Polars uses pl.concat([left, right], how="horizontal").

Pandas equivalent: pd.concat([left, right], axis=1)

In Pandas, horizontal concat is pd.concat([df1, df2], axis=1). Unlike Polars, Pandas aligns on the index, so mismatched indexes produce NaN fill rather than an error. Use .reset_index(drop=True) on both frames before concat to avoid unintended alignment.

Attaching Columns Side by Side

Polars | Horizontally concatenate a symbol-price frame with a sector frame using pl.concat()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Combines a 2-row symbol/price frame with a 2-row sector frame using how="horizontal", producing a 2-row, 3-column result — both frames must have matching row counts for horizontal concat to succeed.

left = pl.DataFrame({"symbol": ["A", "B"], "price": [100, 200]})
right = pl.DataFrame({"sector": ["Tech", "Luxury"]})
display(pl.concat([left, right], how="horizontal"))
Rendered tabular output preserved below.
symbolpricesector
stri64str
A100Tech
B200Luxury

Diagonal Concat (Polars Only)

Diagonal concat stacks DataFrames vertically even when their schemas differ. Columns present in one frame but absent in another are filled with null. Use this when combining data from heterogeneous sources — e.g., merging two API responses with slightly different field sets — without needing to align schemas manually first.

No Pandas equivalent

Pandas pd.concat() raises a column mismatch error when schemas differ unless join='outer' is specified, which fills missing columns with NaN — functionally similar but uses NaN (float) rather than native null, causing type coercion.

Schema-Tolerant Vertical Stack

Polars | Diagonally concatenate two frames with different schemas, filling missing columns with null

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Stacks a {symbol, close} frame and a {symbol, volume} frame using how="diagonal", producing a 2-row, 3-column result where close is null for row B and volume is null for row A.

a = pl.DataFrame({"symbol": ["A"], "close": [100.0]})
b = pl.DataFrame({"symbol": ["B"], "volume": [999]})
display(pl.concat([a, b], how="diagonal"))
Rendered tabular output preserved below.
symbolclosevolume
strf64i64
A100.0null
Bnull999

Combination Crosswalk

OpPandasPolars
Inner.merge(how=‘inner’).join(how=‘inner’)
Left.merge(how=‘left’).join(how=‘left’)
AntiN/A.join(how=‘anti’)
SemiN/A.join(how=‘semi’)
Cross.merge(how=‘cross’).join(how=‘cross’)
Stackpd.concat()pl.concat()
DiagonalN/Apl.concat(how=‘diagonal’)

Reshaping

Reshaping transforms the structure of a DataFrame without changing the underlying data. The two fundamental operations are wide to long (melt/unpivot — spread column names into rows) and long to wide (pivot — collapse row values into columns). Additional operations include explode (list column to rows), implode (rows to list), transpose, and one-hot encoding.

Wide to Long: melt / unpivot

Melt (Pandas) / unpivot (Polars) converts a wide DataFrame — where multiple columns represent the same measurement at different points — into a long format where column names become values in a variable column and their values go into a value column. This is required before plotting multi-series charts, applying long-format aggregations, or loading into a normalized database table.

Spreading Column Names into Rows

Pandas | Melt ASML.AS OHLC columns into long format with price_type and price columns

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Takes the last 3 rows of ASML.AS with open, high, low, close columns and melts them into a 12-row long frame where price_type holds the column name and price holds the value — keeping date as the identity variable.

# Pandas melt
asml_pd = ohlcv_pd[ohlcv_pd["symbol"]=="ASML.AS"][["date","open","high","low","close"]].tail(3)
display(asml_pd.melt(id_vars="date", var_name="price_type", value_name="price"))
Rendered tabular output preserved below.
dateprice_typeprice
02026-03-10open1188.4
12026-03-11open1188.4
22026-03-12open1194.8
32026-03-10high1208.4
42026-03-11high1210.8
52026-03-12high1202.2
62026-03-10low1172.2
72026-03-11low1174.0
82026-03-12low1187.8
92026-03-10close1200.0
102026-03-11close1198.8
112026-03-12close1190.8

Polars | Unpivot ASML.AS OHLC columns into long format with price_type and price columns

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Replicates the Pandas melt using .unpivot(index="date", variable_name="price_type", value_name="price"), producing the same 12-row result with Float64 typed price values and a Polars-native API name.

# Polars unpivot
asml_pl = ohlcv_pl.filter(pl.col("symbol")=="ASML.AS").select("date","open","high","low","close").tail(3)
display(asml_pl.unpivot(index="date", variable_name="price_type", value_name="price"))
Rendered tabular output preserved below.
dateprice_typeprice
datestrf64
2026-03-10open1188.4
2026-03-11open1188.4
2026-03-12open1194.8
2026-03-10high1208.4
2026-03-11high1210.8
2026-03-12high1202.2
2026-03-10low1172.2
2026-03-11low1174.0
2026-03-12low1187.8
2026-03-10close1200.0
2026-03-11close1198.8
2026-03-12close1190.8

Long to Wide: pivot

Pivot converts a long-format DataFrame into wide format by spreading the unique values of an on column into new columns, filling each cell with a corresponding values column. Use pivot to create a symbol-by-year close-price matrix from daily time series data, or to produce a sector-by-metric scorecard. Polars .pivot(on=, index=, values=) performs this eagerly; Pandas uses pivot_table() which also supports aggregation functions for duplicate index combinations.

Spreading Row Values into Columns

Polars | Pivot annual average close prices into a symbol × year matrix using pivot()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Groups ASML.AS, MC.PA, and SAP.DE by symbol and year to compute annual average close, then pivots year values into columns — producing a 3-row, 7-column matrix of average prices from 2021 to 2026.

# Polars pivot
ohlcv_yr = ohlcv_pl.with_columns(pl.col("date").dt.year().alias("year"))
pivoted = (
    ohlcv_yr.filter(pl.col("symbol").is_in(["ASML.AS","MC.PA","SAP.DE"]))
    .group_by("symbol","year").agg(pl.col("close").mean().round(2).alias("avg"))
    .pivot(on="year", index="symbol", values="avg").sort("symbol")
)
display(pivoted)
Rendered tabular output preserved below.
symbol202420212026202320222025
strf64f64f64f64f64f64
ASML.AS799.26593.611170.42611.33531.58724.61
MC.PA705.65630.22560.63788.14645.6562.7
SAP.DE188.51116.57182.11122.4396.91243.04

Explode

Explode expands a list-typed column so that each element in the list becomes its own row, repeating the non-list columns. Use this after a group_by().agg(pl.col("x").implode()) to restore a collected list back to rows, or when ingesting JSON/Parquet data where one field contains a list of tags or events. Both Pandas and Polars support .explode("col").

Expanding List Columns to Rows

Polars | Explode a tags list column so each tag becomes its own row using explode()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Takes a 2-row DataFrame where each symbol has a list of 2 tags, then explodes the tags column into 4 rows — repeating the symbol value for each tag element.

df = pl.DataFrame({"symbol": ["ASML.AS","MC.PA"], "tags": [["tech","nl"],["luxury","fr"]]})
display(df.explode("tags"))
Rendered tabular output preserved below.
symboltags
strstr
ASML.AStech
ASML.ASnl
MC.PAluxury
MC.PAfr

Implode

Implode (Polars only) is the inverse of explode: it collects all values in a group into a single list[T] column. Use it to create a “bag of symbols per sector” column, or to bundle related values before serializing to JSON. Pandas has no direct equivalent — the closest is .groupby("sector")["symbol"].apply(list).

Collecting Rows into Lists

Polars | Group symbols by sector into a list column per sector using group_by() and implode()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Groups the scores table by sector and collects all symbol values into a list[str] column per sector using .implode(), showing the first 5 sectors sorted alphabetically — each as a bag of symbol strings.

display(scores_pl.group_by("sector").agg(pl.col("symbol").implode()).sort("sector").head(5))
Rendered tabular output preserved below.
sectorsymbol
strlist[str]
Basic Materials[AI.PA, BAS.DE, … LIN]
Communication Services[DTE.DE, DTE.DE, … NFLX]
Consumer Cyclical[VOW.DE, ADS.DE, … TSLA]
Consumer Defensive[ABI.BR, AD.AS, … COST]
Energy[TTE.PA, ENI.MI, … XOM]

Transpose

Transpose swaps rows and columns — the row index becomes column names and vice versa. Use it to convert a small “symbol × metric” frame into a “metric × symbol” view, e.g., for display in a dashboard table. Polars .transpose(include_header=True, column_names=col) names the output columns from a string column in the input. Pandas uses .T (the transposed property). Both require a homogeneous schema (all numeric, or all string) for the transposed columns.

Swapping Rows and Columns

Polars | Transpose a 2-symbol × 2-metric frame into a 2-metric × 2-symbol view using transpose()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Selects the most recent composite and momentum scores for ASML.AS and MC.PA, then transposes the 2-row, 3-column frame so metric names become rows and symbol values become columns — using column_names="symbol" to name the output columns.

# Need unique rows per symbol for transpose (scores has multiple dates)
small = (
    scores_pl
    .sort("score_date", descending=True)
    .unique(subset=["symbol"], keep="first")
    .filter(pl.col("symbol").is_in(["ASML.AS", "MC.PA"]))
    .select("symbol", "composite_score", "momentum_score")
)
display(small)
display(small.transpose(include_header=True, column_names="symbol"))
Rendered tabular output preserved below.
symbolcomposite_scoremomentum_score
strf64f64
ASML.AS0.1761041.470134
MC.PA-0.203769-0.70792
columnASML.ASMC.PA
strf64f64
composite_score0.176104-0.203769
momentum_score1.470134-0.70792

One-Hot Encoding

One-hot encoding converts a categorical column into N binary columns (one per unique value), where each cell is 1 if the row belongs to that category and 0 otherwise. Required for ML feature engineering — most scikit-learn estimators require numeric input. Polars uses .to_dummies() on the DataFrame; Pandas uses pd.get_dummies(df, columns=["sector"]).

Encoding Categoricals as Binary Columns

Polars | One-hot encode a sector column into binary dummy columns using to_dummies()

This example shows the stated DataFrame operation in runnable form so you can inspect the transformation before reading the preserved output. Converts a 4-row sector column with values Tech, Luxury, and Energy into 3 binary columns (sector_Energy, sector_Luxury, sector_Tech) using to_dummies() — each column uses u8 dtype for memory efficiency.

df = pl.DataFrame({"sector": ["Tech","Luxury","Tech","Energy"]})
display(df.to_dummies())
Rendered tabular output preserved below.
sector_Energysector_Luxurysector_Tech
u8u8u8
001
010
001
100

Reshape Crosswalk

OpPandasPolars
Wide to longmelt()unpivot()
Long to widepivot_table()pivot()
Explodeexplode()explode()
ImplodeN/Aimplode()
Transpose.T.transpose()
One-hotget_dummies()to_dummies()

Operational Risks

Join Expansion Risks

Many-to-many joins multiply rows

If duplicated keys appear on both sides, merge() or join() can produce more rows than either input. Treat validate= or explicit key-uniqueness checks as a precondition whenever row count matters. Estimate the output size of a duplicated-key join before running it.

left_rows = 1_000
matches_per_key = 5
print(left_rows * matches_per_key)
5000

Cross joins scale quadratically

A how="cross" operation grows as left_rows * right_rows, so even moderate inputs become expensive quickly. Filter one side first or replace the join with a smaller driver table. Compute the expected row count before issuing a cross join.

left_rows = 10_000
right_rows = 10_000
print(left_rows * right_rows)
100000000

Ordering and Window Risks

Polars group_by() output order is not stable

Polars group_by() does not guarantee insertion order, so a grouped result should be followed by .sort(...) whenever you present or compare the output. The grouped values are correct, but the row order is not part of the contract. Compare an unsorted label sequence with the explicit sorted(...) version you want to present.

labels = ["Utilities", "Energy", "Technology"]
print(labels)
print(sorted(labels))
['Utilities', 'Energy', 'Technology']
['Energy', 'Technology', 'Utilities']

as_index=False should not be your only portability plan

as_index=False is convenient, but version-sensitive pipelines are safer if they can also tolerate an explicit .reset_index() step. The real contract is a flat output schema, not a single spelling. Show the two flat-schema strategies that keep grouped keys accessible as columns.

strategies = ["groupby(..., as_index=False).agg(...)", "groupby(...).agg(...).reset_index()"]
for item in strategies:
    print(item)
groupby(..., as_index=False).agg(...)
groupby(...).agg(...).reset_index()

Rolling windows need sorted data

rolling() and rolling_mean() operate in storage order, so unsorted timestamps make the statistic meaningless even when the syntax succeeds. Always sort by the logical key, usually date, before applying the window. Compare the storage order with the correctly sorted order for a time-based window.

dates = ["2026-03-12", "2026-03-10", "2026-03-11"]
print(dates)
print(sorted(dates))
['2026-03-12', '2026-03-10', '2026-03-11']
['2026-03-10', '2026-03-11', '2026-03-12']

Reshape Risks

pivot() fails on duplicate coordinates

A strict pivot() requires each (index, column) pair to be unique. If duplicates exist, aggregate first with pivot_table() or a grouped pre-step so the reshape has one value per cell. Check whether the (index, column) coordinate set is unique before pivoting.

pairs = [("ASML.AS", 2024), ("ASML.AS", 2024)]
print(len(pairs), len(set(pairs)))
2 1

High-cardinality encoding widens fast

get_dummies() and to_dummies() create one output column per distinct category, so a high-cardinality feature can explode the width of the table. Bucket rare values before encoding if the category space is unbounded. Treat the number of dummy columns as a direct function of category count.

unique_categories = 10_000
print(unique_categories)
10000

Join and Shape Guards

Validate join cardinality with validate=

Use validate="one_to_one" or validate="many_to_one" when the join semantics are known up front. Failing fast is cheaper than auditing a silently multiplied result later. Keep the accepted join shapes explicit instead of relying on defaults.

for rule in ["one_to_one", "many_to_one"]:
    print(rule)
one_to_one
many_to_one

Check row counts after every structural operation

An immediate result.shape[0] comparison catches bad joins, bad explodes, and bad pivots before the mistake leaks into later calculations. Structural assertions are low-cost and high-signal. Compare the expected and actual row count after a shape-changing step.

expected_rows = 66_355
actual_rows = 66_355
print(expected_rows == actual_rows)
True

Limit how="cross" to a filtered driver set

When a cross join is the right tool, reduce one side first so the Cartesian product is intentional and bounded. A small driver table makes the cost legible. Cross a filtered 50-row driver with 20 scenarios instead of the full fact table.

driver_rows = 50
scenario_rows = 20
print(driver_rows * scenario_rows)
1000

Grouping and Window Patterns

Sort after Polars group_by()

Treat .sort(...) as part of the display contract for grouped Polars output. That keeps comparisons and downstream joins stable even though the aggregate values were already correct. Append the presentation sort immediately after the grouped aggregate.

pipeline = 'df.group_by("sector").agg(...).sort("sector")'
print(pipeline)
df.group_by("sector").agg(...).sort("sector")

Use named aggregation aliases

Named outputs like avg_close or total_volume are easier to merge and reason about than MultiIndex column headers. The alias is part of the schema design, not just a cosmetic cleanup. Name grouped outputs at aggregation time instead of renaming later.

aliases = ["avg_close", "total_volume", "trading_days"]
for alias in aliases:
    print(alias)
avg_close
total_volume
trading_days

Prefer .over() for Polars same-length metrics

When the goal is a same-length grouped result, .over(...) keeps the logic in one expression tree and reads more clearly than detouring through a separate grouped object. Use it for ranks, grouped means, and grouped normalizations. Express a same-length grouped metric with a single .over(...) expression.

expr = 'pl.col("price").mean().over("sector").alias("sector_avg")'
print(expr)
pl.col("price").mean().over("sector").alias("sector_avg")

Reshape Patterns

Prefer unpivot() when normalizing wide columns

For new Polars code, unpivot() names the operation directly and reads cleanly beside pivot(). Use it when wide measurement columns need to become (variable, value) rows. Normalize multiple measurement columns into a variable and value pair.

columns = ["open", "high", "low", "close"]
print(" -> ".join(columns))
open -> high -> low -> close

Python Aggregation and Reshaping Troubleshooting

Join and Grouping Failures

Join produces more rows than the left table

If a left-side preservation join returns extra rows, duplicated keys on the right side are the first place to look. Check n_unique() or deduplicate before repeating the join. Compare total right-side keys with unique right-side keys to spot fanout risk.

right_keys = ["A", "A", "B"]
print(len(right_keys), len(set(right_keys)))
3 2

group_by() appears to skip null-key rows

Missing groups often come from null keys rather than a broken aggregation. Count nulls in the grouping column first, then decide whether to fill them or analyze them separately. Measure how many grouping keys are null before aggregating.

keys = ["Tech", None, "Energy", None]
print(sum(key is None for key in keys))
2

Anti joins return no rows

An empty how="anti" join means every left key matched, or that the key comparison is not testing what you think it is because of dtype or whitespace issues. Confirm both the values and the normalized representation. Trim and compare candidate keys before assuming the anti join is wrong.

left_key = "ASML.AS "
right_key = "ASML.AS"
print(left_key == right_key)
print(left_key.strip() == right_key.strip())
False
True

Window and Pivot Failures

pivot() raises a duplicate-entry error

A duplicate-entry ValueError means at least one (index, column) coordinate maps to more than one value. Aggregate first so each cell has one scalar. Use coordinate cardinality to confirm whether a strict pivot can succeed.

coordinates = [("ASML.AS", 2024), ("ASML.AS", 2024), ("MC.PA", 2024)]
print(len(coordinates) == len(set(coordinates)))
False

Rolling means return only null or NaN

If the window is larger than the available rows, or the data is not sorted, the early or entire result can be null-heavy. Verify both the window size and the ordering assumption. Compare the available row count with the requested rolling window size.

available_rows = 3
window_size = 7
print(available_rows < window_size)
True

Schema and Nested-Data Failures

concat() introduces unexpected nulls

Unexpected nulls after concat() usually mean the input schemas were not aligned. Compare column sets and dtypes before stacking, or use Polars how="diagonal" deliberately. Check whether two frames expose the same column names before concatenating them vertically.

left_cols = {"symbol", "close"}
right_cols = {"symbol", "volume"}
print(left_cols == right_cols)
False

explode() fails because the column is not list-typed

explode() expects list-like values; a scalar column will fail or behave unexpectedly. Inspect the schema first and cast or restructure the data before exploding. Test whether the target values behave like lists before calling explode().

value = "tech"
print(isinstance(value, list))
False

One-hot encoding produces too many columns

If get_dummies() or to_dummies() generates an unmanageable width, the source category has too many unique values. Bucket rare values or encode only the top categories you intend to model. Treat unique-category count as the upper bound on dummy-column count.

unique_values = 4_096
print(unique_values > 100)
True