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])

print(closes.rolling(3).mean())
# -> 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,
                     "MA3": closes.rolling(3).mean().round(2)})

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
print((102.0 + 101.0 + 105.0) / 3)      # -> 102.66666666666667
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 AAPL, MSFT and NVDA over 40 business days. The prices are simulated, not downloaded.

I keep the AAPL rows and add two columns.

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

aapl = prices[prices["Ticker"] == "AAPL"].copy()

aapl["MA5"]  = aapl["Close"].rolling(5).mean()
aapl["MA10"] = aapl["Close"].rolling(10).mean()

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 AAPL row and MA10 on the tenth. A longer window costs more NaN at the front:

print(aapl["MA5"].isna().sum())      # -> 4
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
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 MSFT 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"] == "MSFT"].copy()
msft["MA5"] = msft["Close"].rolling(5).mean()

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 AAPL: 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())
# -> [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.