What is a moving average?

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 pd

closes = pd.Series([100.0, 102.0, 101.0, 105.0, 104.0, 108.0])   # six daily closes, oldest first

print(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 dp

print(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 2
print((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 stacked

aapl = prices[prices["Ticker"] == "AAA"].copy()     # one ticker, copied before adding columns

aapl["MA5"]  = aapl["Close"].rolling(5).mean()       # short window, tracks the price closely
aapl["MA10"] = aapl["Close"].rolling(10).mean()      # long window, slower to turn

print(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 window
print(aapl["MA10"].isna().sum())     # -> 9
4
9

A window of n gives n - 1 empty rows.

Step 4. The last row, both windows

print(aapl[["Date", "Close", "MA5", "MA10"]].tail(3))
# ->            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
           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:

print(round(aapl["Close"].tail(5).mean(), 2))    # -> 171.88
print(round(aapl["Close"].tail(10).mean(), 2))   # -> 173.45
171.88
173.45

Step 5. The same window, a different statistic

.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 window
print(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?

import pandas as pd

prices = pd.read_csv("prices.csv")

msft = prices[prices["Ticker"] == "CCC"].copy()    # keep the CCC rows only
msft["MA5"] = msft["Close"].rolling(5).mean()       # 5-day mean down those rows

print(msft["MA5"].isna().sum())    # -> 4
print(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.

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.