Take the mean of the last n values at every row with .rolling(n).mean(), and put a 5-day and a 10-day average on a price column.
A moving average is the mean of the last n values, worked out again at every row. On day 20 a 3-day average is the mean of days 18, 19 and 20. On day 21 it is the mean of days 19, 20 and 21. The window slides forward one row at a time.
.rolling(n).mean() does it in one call.
In this lesson I run a 3-day average on six closes and check one window by hand, then put a 5-day and a 10-day average on a price column from a CSV and compare them on the last row.
Step 1. Six closes, a 3-day average
import pandas as pdcloses = pd.Series([100.0, 102.0, 101.0, 105.0, 104.0, 108.0]) # six daily closes, oldest firstprint(closes.rolling(3).mean()) # 3-day mean at every row# -> 0 NaN# -> 1 NaN# -> 2 101.000000# -> 3 102.666667# -> 4 103.333333# -> 5 105.666667# -> dtype: float64
0 NaN
1 NaN
2 101.000000
3 102.666667
4 103.333333
5 105.666667
dtype: float64
Six values in, six values out. .rolling(3) does not shorten the Series, it lines the average up with the last day of each window.
The first two are NaN. Row 0 has one observation behind it and row 1 has two, and a 3-day window needs three. Row 2 is the first row with a full window, so it is the first row with a number.
Side by side:
side = pd.DataFrame({"Close": closes, # the raw closes"MA3": closes.rolling(3).mean().round(2)}) # the average beside them, 2 dpprint(side)# -> Close MA3# -> 0 100.0 NaN# -> 1 102.0 NaN# -> 2 101.0 101.00# -> 3 105.0 102.67# -> 4 104.0 103.33# -> 5 108.0 105.67
Close MA3
0 100.0 NaN
1 102.0 NaN
2 101.0 101.00
3 105.0 102.67
4 104.0 103.33
5 108.0 105.67
Step 2. Check one window by hand
Row 2 covers rows 0, 1 and 2: the closes 100, 102 and 101.
print((100.0+102.0+101.0) /3) # -> 101.0 <- the window sitting on row 2print((102.0+101.0+105.0) /3) # -> 102.66666666666667 <- slid on one row
101.0
102.66666666666667
101.0 is what row 2 printed and 102.666667 is what row 3 printed. Row 3 dropped 100 off the back and took 105 on the front. That dropping and taking is the whole of .rolling().
Step 3. A 5-day and a 10-day average on a price column
prices.csv sits next to this lesson: Date, Ticker, Close and Volume for AAA, CCC and DDD over 40 business days. The prices are simulated, not downloaded.
I keep the AAA rows and add two columns.
prices = pd.read_csv("prices.csv") # 40 dates, three tickers stackedaapl = prices[prices["Ticker"] =="AAA"].copy() # one ticker, copied before adding columnsaapl["MA5"] = aapl["Close"].rolling(5).mean() # short window, tracks the price closelyaapl["MA10"] = aapl["Close"].rolling(10).mean() # long window, slower to turnprint(aapl.shape) # -> (40, 6)print(aapl[["Date", "Close", "MA5", "MA10"]].head(11))# -> Date Close MA5 MA10# -> 0 2026-01-02 186.66 NaN NaN# -> 3 2026-01-05 186.38 NaN NaN# -> 6 2026-01-06 188.51 NaN NaN# -> 9 2026-01-07 187.24 NaN NaN# -> 12 2026-01-08 189.27 187.612 NaN# -> 15 2026-01-09 185.32 187.344 NaN# -> 18 2026-01-12 185.46 187.160 NaN# -> 21 2026-01-13 187.01 186.860 NaN# -> 24 2026-01-14 185.68 186.548 NaN# -> 27 2026-01-15 187.83 186.260 186.936# -> 30 2026-01-16 188.11 186.818 187.081
(40, 6)
Date Close MA5 MA10
0 2026-01-02 186.66 NaN NaN
3 2026-01-05 186.38 NaN NaN
6 2026-01-06 188.51 NaN NaN
9 2026-01-07 187.24 NaN NaN
12 2026-01-08 189.27 187.612 NaN
15 2026-01-09 185.32 187.344 NaN
18 2026-01-12 185.46 187.160 NaN
21 2026-01-13 187.01 186.860 NaN
24 2026-01-14 185.68 186.548 NaN
27 2026-01-15 187.83 186.260 186.936
30 2026-01-16 188.11 186.818 187.081
The row labels run 0, 3, 6, 9 because they are the labels these rows carried in the full table, where every date holds three tickers. .rolling() works down the rows in the order they sit, so the labels do not matter here.
MA5 starts on the fifth AAA row and MA10 on the tenth. A longer window costs more NaN at the front:
print(aapl["MA5"].isna().sum()) # -> 4 <- rows short of a full windowprint(aapl["MA10"].isna().sum()) # -> 9
Date Close MA5 MA10
111 2026-02-24 172.64 172.954 175.886
114 2026-02-25 171.42 172.502 174.767
117 2026-02-26 169.42 171.880 173.449
On 2026-02-26 the close is 169.42, the 5-day average is 171.88 and the 10-day average is 173.45. The close sits below both, and the 5-day sits below the 10-day, because the price has been falling and the short window holds only recent, lower closes while the long window still carries older, higher ones.
Both numbers are plain means of the last few closes:
.rolling(n) on its own computes nothing. It hands you a window, and the method after it says what to do with the values inside.
print(round(aapl["Close"].rolling(5).std().iloc[-1], 4)) # -> 1.7394 <- .iloc[-1] takes the last windowprint(round(aapl["Close"].rolling(5).max().iloc[-1], 4)) # -> 174.17
1.7394
174.17
The standard deviation of the last five closes is 1.7394 and the highest of them is 174.17. .min(), .sum() and .median() follow .rolling() the same way.
Your turn
Take the CCC rows out of prices.csv, add a 5-day moving average, and print the last three rows. How many rows come out NaN?
TipShow answer
import pandas as pdprices = pd.read_csv("prices.csv")msft = prices[prices["Ticker"] =="CCC"].copy() # keep the CCC rows onlymsft["MA5"] = msft["Close"].rolling(5).mean() # 5-day mean down those rowsprint(msft["MA5"].isna().sum()) # -> 4print(msft[["Date", "Close", "MA5"]].tail(3))# -> Date Close MA5# -> 112 2026-02-24 429.00 426.720# -> 115 2026-02-25 435.51 427.614# -> 118 2026-02-26 435.94 429.438
Four, the same as AAA: a 5-day window leaves n - 1 = 4 rows short of a full window.
NoteFilling the first rows anyway
min_periods sets how many observations a window needs before it returns a number.
print(closes.rolling(3, min_periods=1).mean().round(2).tolist()) # one observation is enough# -> [100.0, 101.0, 101.0, 102.67, 103.33, 105.67]
[100.0, 101.0, 101.0, 102.67, 103.33, 105.67]
Row 0 is now the mean of one close and row 1 the mean of two, so nothing is NaN. Those two rows are not 3-day averages, though, and if you compare them with the rest you are comparing a mean of one against a mean of three. The default, min_periods=n, keeps them empty instead.