Pandas · Lesson 10 of 12
Rolling, Expanding and Window Calculations
Compute moving averages, running totals and group windows in pandas with rolling, expanding, ewm, shift, diff and time-based windows like rolling('7D').
- Intermediate
- 17 min read
- 4 objectives
Before this lessonLesson 9: Dates, Plots and Exporting
What you will learn
- Use rolling windows with min_periods, center and time-based sizes
- Compute running totals and records with expanding and cumulative methods
- Smooth noisy data with exponentially weighted averages
- Apply window calculations per group without leaking across groups
Your Progress
0 of 12 lessons 0%
- Lessons0 / 12
- Completed0
- Est. time left~ 3 hours
Create a free account to keep your progress on every device.
Tip: pressing Next marks this lesson complete automatically.
The dates lesson introduced rolling(7).mean() and shift in a few lines. Window calculations deserve a closer look, because they answer many everyday business questions: What is the 7-day average of signups? What is our running revenue for the year? How does each day compare to the same day last week? Is this value unusually high compared to recent history?
A window is a set of neighbouring rows that pandas looks at together. The three main kinds are rolling (a fixed-size window that slides along), expanding (everything from the start up to now) and exponentially weighted (all history, with recent rows counting more). All of them assume your rows are in time order, so sort first.
Rolling windows
rolling(n) groups each row with the n - 1 rows before it, then you choose an aggregation such as mean, sum, max or std. The first n - 1 results are missing because the window is not full yet. min_periods lets pandas compute a result from a partial window.
import pandas as pd
signups = pd.Series(
[12, 15, 9, 20, 18, 25, 30],
index=pd.date_range("2026-06-01", periods=7, freq="D"),
name="signups",
)
df = signups.to_frame()
df["avg_3"] = signups.rolling(3).mean().round(1)
df["avg_3_partial"] = signups.rolling(3, min_periods=1).mean().round(1)
df["max_3"] = signups.rolling(3).max()
print(df)signups avg_3 avg_3_partial max_3 2026-06-01 12 NaN 12.0 NaN 2026-06-02 15 NaN 13.5 NaN 2026-06-03 9 12.0 12.0 15.0 2026-06-04 20 14.7 14.7 20.0 2026-06-05 18 15.7 15.7 20.0 2026-06-06 25 21.0 21.0 25.0 2026-06-07 30 24.3 24.3 30.0
By default the window is trailing: each value summarizes the current row and the ones before it, which is what you want for anything reported "as of" a day. center=True puts the current row in the middle of the window instead. That is nicer for smoothing a chart after the fact, but it peeks into the future, so never use it for features in a forecasting model.
Time-based windows
Real data has gaps: no orders on a holiday, a sensor that went offline. rolling(3) always means "3 rows", no matter how far apart they are. With a DatetimeIndex you can pass a duration like "3D" instead, and pandas includes whichever rows fall inside that span of time.
import pandas as pd
orders = pd.Series(
[100, 80, 120, 90],
index=pd.to_datetime(["2026-06-01", "2026-06-02", "2026-06-05", "2026-06-06"]),
)
out = pd.DataFrame({
"amount": orders,
"last_3_rows": orders.rolling(3).sum(),
"last_3_days": orders.rolling("3D").sum(),
})
print(out)amount last_3_rows last_3_days 2026-06-01 100 NaN 100.0 2026-06-02 80 NaN 180.0 2026-06-05 120 300.0 120.0 2026-06-06 90 290.0 210.0
On June 5 the row-based window adds up June 1, 2 and 5 (spanning five days), while the time-based window correctly only includes June 5 itself, because June 3 and 4 had no orders. Time-based windows also compute a value from the first row onward, since they use min_periods=1 by default.
Expanding windows and running totals
An expanding window starts at the first row and grows by one each step, so it answers "from the beginning until now" questions. For sums and extremes there are also shortcut methods: cumsum, cummax, cummin and cumprod.
import pandas as pd
revenue = pd.Series([120, 90, 150, 60, 200], name="revenue",
index=["mon", "tue", "wed", "thu", "fri"])
df = revenue.to_frame()
df["running_total"] = revenue.cumsum()
df["running_avg"] = revenue.expanding().mean().round(1)
df["best_so_far"] = revenue.cummax()
df["is_record"] = revenue == revenue.cummax()
print(df)revenue running_total running_avg best_so_far is_record mon 120 120 120.0 120 True tue 90 210 105.0 120 False wed 150 360 120.0 150 True thu 60 420 105.0 150 False fri 200 620 124.0 200 True
Exponentially weighted averages
A simple rolling mean treats every day in the window equally and forgets everything outside it, which makes it jump when a big value enters or leaves. ewm (exponentially weighted moving) gives every past value a weight that shrinks the older it is. The span parameter is roughly comparable to a rolling window size. It reacts faster to recent changes while staying smooth.
import pandas as pd
latency = pd.Series([100, 102, 98, 101, 180, 99, 100], name="ms")
df = latency.to_frame()
df["rolling_3"] = latency.rolling(3).mean().round(1)
df["ewm_3"] = latency.ewm(span=3, adjust=False).mean().round(1)
print(df)ms rolling_3 ewm_3 0 100 NaN 100.0 1 102 NaN 101.0 2 98 100.0 99.5 3 101 100.3 100.2 4 180 126.3 140.1 5 99 126.7 119.6 6 100 126.3 109.8
Comparing to earlier rows: shift, diff, pct_change
Many metrics compare a value with an earlier one. shift(n) moves values down by n rows, so you can line up "today" with "n rows ago". diff(n) and pct_change(n) are shortcuts for the difference and percentage change. With daily data, n=7 gives week-over-week comparisons that cancel out weekday effects.
import pandas as pd
visits = pd.Series([200, 220, 210, 260, 250],
index=pd.date_range("2026-06-01", periods=5, freq="D"))
df = pd.DataFrame({
"visits": visits,
"prev": visits.shift(1),
"change": visits.diff(),
"pct": (visits.pct_change() * 100).round(1),
})
print(df)visits prev change pct 2026-06-01 200 NaN NaN NaN 2026-06-02 220 200.0 20.0 10.0 2026-06-03 210 220.0 -10.0 -4.5 2026-06-04 260 210.0 50.0 23.8 2026-06-05 250 260.0 -10.0 -3.8
Windows per group
With several series in one long table (one row per store per day, say), a plain rolling mean would slide straight from the end of one store into the start of the next. Group first, and the window restarts for each group. groupby(...).rolling(...) returns a result with the group added to the index; transform keeps the original row order so you can assign it back as a column.
import pandas as pd
df = pd.DataFrame({
"store": ["A", "A", "A", "B", "B", "B"],
"day": [1, 2, 3, 1, 2, 3],
"sales": [10, 20, 30, 100, 200, 300],
}).sort_values(["store", "day"])
df["avg_2"] = df.groupby("store")["sales"].transform(lambda s: s.rolling(2).mean())
df["cum_sales"] = df.groupby("store")["sales"].cumsum()
df["prev_day"] = df.groupby("store")["sales"].shift(1)
print(df)store day sales avg_2 cum_sales prev_day 0 A 1 10 NaN 10 NaN 1 A 2 20 15.0 30 10.0 2 A 3 30 25.0 60 20.0 3 B 1 100 NaN 100 NaN 4 B 2 200 150.0 300 100.0 5 B 3 300 250.0 600 200.0
Look at the first row of store B: its avg_2 and prev_day are missing instead of borrowing store A's last value. That is exactly what you want.
Recap
- Sort by time first; windows follow row order.
rolling(n)uses the last n rows;rolling("7D")uses the last 7 days and handles gaps correctly.expanding()andcumsum/cummaxanswer "from the start until now" questions.ewm(span=n)smooths while reacting faster to recent changes;shift,diffandpct_changecompare to earlier rows.- Use
groupbywithtransform,cumsumorshiftso windows never cross group boundaries.
# Write your solution here
Finished reading? Mark this lesson complete to track your progress.
