Calculate Moving Averages with pandas rolling
Daily figures are noisy: a Saturday dip, a promotion spike, a delivery that landed on Monday instead of Friday. A moving average smooths them into a trend a reader can see. In Excel it is an AVERAGE over a range that slides down with the row, or a chart trendline; in pandas it is rolling(...).mean(). The call is short, but it hides three choices that change the numbers — whether the window counts rows or days, what happens before the window is full, and how the window is anchored — plus one trap when rolling within groups. This page, part of Creating Pivot Tables from Excel Data with Pandas, covers each.
Prerequisites
pip install pandas openpyxl
daily_sales.xlsx has two weeks of sales for two stores. North has a row for every day; South only has rows on six of the fourteen days, the typical shape of an export that records only days with activity.
Step 1 — A trailing 7-day average on complete data
For a series with one row per day, rolling(7).mean() averages each row with the six before it:
import pandas as pd
sales = pd.read_excel("daily_sales.xlsx").sort_values(["store", "date"])
north = sales[sales["store"] == "North"].set_index("date")["sales"]
ma = north.rolling(7).mean()
print(pd.DataFrame({"sales": north, "ma7": ma.round(1)}))
The first six values are NaN, because a seven-row window is not full until the seventh day; from 7 March onwards each value is the average of that day and the six before, the same as =AVERAGE(B2:B8) in Excel dragged down the column. Sort by date first — rolling uses the rows in the order they are, and an unsorted export produces averages of unrelated days.
Step 2 — Decide what happens before the window is full
min_periods sets how many rows the window needs before it returns a value. With min_periods=1, the first rows average whatever is available:
early = north.rolling(7, min_periods=1).mean()
print(early.head(7).round(1).tolist())
That removes the gap at the start of a chart, at the cost of early values based on fewer days — the first "7-day average" is a single day. If you show them, mark them: a lighter colour in the chart, or a window_days column that says how many days each value used. For a dashboard that must show the trend from day one, a sensible compromise is min_periods=4, which starts once more than half the window is filled.
Step 3 — Count days, not rows, when days are missing
South has no rows on the quiet days. rolling(7) still takes seven rows, which for South spans far more than a week; rolling("7D") on a date index takes the rows whose dates fall within the last seven days:
south = sales[sales["store"] == "South"].set_index("date")["sales"]
compare = pd.DataFrame({
"sales": south,
"rows_7": south.rolling(7, min_periods=1).mean().round(1),
"days_7D": south.rolling("7D").mean().round(1),
})
print(compare)
On 12 March, rows_7 averages every row back to 1 March — twelve days of data — while days_7D averages only the rows from 6 to 12 March. Neither is a true seven-day average of daily sales, though, because missing days are really zero-sales days. When a missing row means "nothing happened", fill the missing dates with zero first and then roll; when it means "no data", the time-based window over the rows that exist is the honest option. Fill Missing Dates in a Time Series from Excel with pandas shows the filling step.
Step 4 — Trailing or centred
A trailing window, the default, averages each day with the days before it, so the average lags the data: a rise shows up in the average a few days late. center=True averages days on both sides, which lines up with the data and is better for analysing past trends:
both = pd.DataFrame({"sales": north,
"trailing": north.rolling(7).mean().round(1),
"centred": north.rolling(7, center=True).mean().round(1)})
print(both.tail(5))
The centred average has no value for the last three days — it needs days that have not happened yet. That is why dashboards showing today use trailing averages, and why a centred chart line stops short of the right-hand edge. Excel's chart trendline "Moving Average" is a trailing average, which matters if you are matching a chart someone built by hand.
Step 5 — Roll within each group
With several stores in one table, each store needs its own window: a moving average that runs across the boundary from North's last day into South's first mixes the two. Roll within groups, and keep the result aligned with the original rows by using transform:
sales = sales.sort_values(["store", "date"])
sales["ma7"] = (sales.groupby("store")["sales"]
.transform(lambda s: s.rolling(7, min_periods=4).mean())
.round(1))
print(sales.groupby("store").tail(3).to_string(index=False))
transform returns a Series indexed like sales, so it can be assigned straight back as a column. The more obvious sales.groupby("store")["sales"].rolling(7).mean() returns a result indexed by store and original row number, which cannot be assigned back without resetting the index — a common source of "cannot reindex" errors and of values landing on the wrong rows. For time-based windows per group, set the date as the index inside the lambda: lambda s: s.set_axis(dates.loc[s.index]).rolling("7D").mean().values, or roll each store separately.
Exponential averages react faster
A simple moving average gives every day in the window the same weight, so a spike stays in the average for exactly seven days and then drops out abruptly. An exponentially weighted average gives recent days more weight and older days progressively less, which reacts faster to real changes and has no cliff edge:
smooth = pd.DataFrame({"sales": north,
"sma7": north.rolling(7).mean().round(1),
"ewm7": north.ewm(span=7, adjust=False).mean().round(1)})
print(smooth.tail(4))
span=7 gives a weighting comparable to a seven-day window. The exponential version follows the weekend dip on 13 and 14 March more closely than the simple average, which is still dominated by the strong midweek days. Use it for alerting and short-term monitoring; keep the simple average where readers need to reproduce the figure with AVERAGE in Excel.
Step 6 — Write the average next to the data
Write the moving average as a column beside the daily figures, so a chart in the workbook can plot both:
out = sales[["date", "store", "sales", "ma7"]]
with pd.ExcelWriter("sales_ma.xlsx", engine="xlsxwriter", datetime_format="yyyy-mm-dd") as writer:
out.to_excel(writer, index=False, sheet_name="Daily")
print(pd.read_excel("sales_ma.xlsx")["ma7"].notna().sum(), "rows with a moving average")
If readers will add days themselves, a formula column keeps working where a static number would not: =IF(COUNT(C2:C8)=7, AVERAGE(C2:C8), "") in row 8, filled down within each store's block. Add a Line Chart to an Excel Report with Python builds the chart.
Common pitfalls
- Rolling unsorted data. Sort by date (and group) first.
- Row windows over gappy data.
rolling(7)on six sparse rows spans weeks; use"7D"or fill the dates. - Rolling across groups. Use
groupby().transformso each store has its own window. - Assigning
groupby().rolling()directly. Its MultiIndex does not line up with the frame. - Early values from
min_periods=1. They are one-day "averages"; mark them or start later.
Performance notes
rolling().mean() is implemented in compiled code and handles millions of rows quickly. The transform with a lambda calls Python once per group, which is fine for hundreds of groups; for tens of thousands, groupby().rolling() followed by reset_index(level=0, drop=True) avoids the per-group Python call and aligns the result by the original row index.
Conclusion
Sort by date, then rolling(7).mean() gives a trailing seven-row average — correct when there is one row per day. Use rolling("7D") or fill missing dates first when days are absent, set min_periods deliberately and mark partial windows, choose trailing for dashboards and centred for analysis, and roll within groups with groupby().transform so every store gets its own window and the result lines up with its rows.
Frequently asked questions
What is the difference between rolling(7) and rolling("7D")? rolling(7) averages the last seven rows, whatever dates they cover. rolling("7D") averages the rows whose dates fall in the last seven days. With one row per day they agree; when days are missing, rolling(7) stretches over a longer period and rolling("7D") averages fewer rows.
Why are the first six values NaN? A seven-row window is not full until the seventh row, and by default rolling returns NaN until it is. Pass min_periods=1 (or another minimum) to average whatever is available, and label those early values as partial.
How do I calculate a moving average per store? Sort by store and date, group by store, and apply the rolling mean inside transform with a lambda, as shown in Step 5. transform keeps the result aligned with the original rows, so it can be assigned straight back as a column.
Is a centred moving average better? It lines up better with the data for analysis of past trends, because each point averages days on both sides. It cannot be computed for the most recent days, so dashboards that must show today usually use a trailing average.
Related
- Up one level: Creating Pivot Tables from Excel Data with Pandas — pivots and summaries.
- Running Totals and Year-Over-Year Growth in pandas — cumulative and comparative windows.
- Group Excel Rows by Month and Quarter with pandas — calendar aggregation instead of smoothing.
- Add a Trendline to an Excel Chart with Python — letting the chart compute the smoothing.
More on pivots & summaries
- How-toBuild a Customer Churn Report from Subscription Data
- How-toRefresh Excel Pivot Tables and Queries with Python
- How-toAdd a Native Excel Pivot Table with Python
- How-toAdd Subtotals and Grand Totals to a pandas Pivot Table
- How-toBin Values into Ranges Like Excel FREQUENCY with pd.cut
- How-toBuild a Cohort Retention Table from Excel Data