How do rolling and expanding windows work in pandas, and when do you use each?
rolling() computes statistics over a fixed-size sliding window, discarding data outside the window; expanding() grows the window from the first row to the current row, equivalent to an ever-increasing cumulative calculation. Both return objects you chain .mean(), .sum(), .std(), or a custom .apply() onto.
How to think about it
What the interviewer is really probing is whether you understand what each window forgets. A rolling window is a sliding spotlight — it sees only the last N rows and deliberately drops everything older. An expanding window is a running total — it never forgets, widening with every new row. Get that distinction and the use cases fall out on their own: rolling for short-term trend (“is this week above the 4-week average?”), expanding for all-time metrics (“what is the running mean up to today?”).
Both work the same way in code. You call .rolling(window=N) or .expanding() to get a window object, then chain the statistic you want — .mean(), .sum(), .std(), or a custom .apply(). The only subtlety worth memorizing is the warm-up: a 3-row rolling window has nothing to average for the first two rows, so it returns NaN there unless you tell it otherwise with min_periods.
Four windows on one series
Put a rolling mean, a rolling mean with min_periods=1, an expanding mean, and an expanding max side by side on the same price series, and the behaviors separate cleanly:
import pandas as pd
prices = pd.Series([10, 12, 11, 14, 13, 15, 16, 14, 17, 18], name="price")
df = pd.DataFrame({"price": prices})
df["sma_3"] = df["price"].rolling(window=3).mean()
df["sma_3_full"] = df["price"].rolling(window=3, min_periods=1).mean()
df["cum_mean"] = df["price"].expanding().mean()
df["running_max"] = df["price"].expanding().max()
print(df.round(2).to_string())
price sma_3 sma_3_full cum_mean running_max
0 10 NaN 10.00 10.00 10.0
1 12 NaN 11.00 11.00 12.0
2 11 11.00 11.00 11.00 12.0
3 14 12.33 12.33 11.75 14.0
4 13 12.67 12.67 12.00 14.0
5 15 14.00 14.00 12.50 15.0
6 16 14.67 14.67 13.00 16.0
7 14 15.00 15.00 13.12 16.0
8 17 15.67 15.67 13.56 17.0
9 18 16.33 16.33 14.00 18.0
Read the columns against each other. sma_3 is NaN for the first two rows — not enough history yet — then tracks only the latest three prices, so it dips and rises sharply with recent moves. sma_3_full fills those early gaps because min_periods=1 lets it average whatever it has. cum_mean climbs slowly and smoothly: each value folds in all prices so far, so one new point barely moves it. And running_max is a ratchet — it only ever goes up, recording the all-time high at every row. Rolling reacts; expanding remembers.
The same machinery extends naturally. On a DatetimeIndex you can pass a time offset instead of a row count — ts.rolling("3D").mean() covers 3 calendar days regardless of how many rows fall inside, which is the correct way to handle irregular timestamps. For per-group features, pair it with groupby and transform so the result stays index-aligned and assigns straight back as a column:
df["rolling_avg"] = (
df.groupby("ticker")["close"]
.transform(lambda s: s.rolling(window=5).mean())
)
And for custom logic, .apply() takes any function over the window — prices.rolling(3).apply(lambda x: x[-1] - x[0], raw=True) computes 3-period momentum; raw=True hands the lambda a NumPy array instead of a Series, skipping pandas overhead and running noticeably faster on large windows.