Skip to content

pandas mean

The six reducers that turn a column into a number — and the one default they all share.

What is the average? That is usually the second question anyone asks of a data set, right after "how big is it?" Pandas answers it with .mean(), and answers the neighboring questions with .sum(), .median(), .std(), .min() and .max(). I teach these six as one family rather than six methods, because they share a signature, they share a set of keyword arguments, and — this is the part that quietly produces wrong numbers — they share a default.

That default is skipna=True. Every one of these methods silently drops missing values before it computes anything. That is almost always what you want. It is also why a mean over a column with gaps is the mean of a smaller sample than you think it is, computed over a denominator that Pandas never shows you and that can be different for every column in the same call.

So the habit I want you to walk away with is small and boring: never report a mean without reporting its count next to it.

Official documentation: DataFrame.mean, DataFrame.sum, DataFrame.median and DataFrame.std. .min() and .max() belong to the same family and behave the same way here. Finding out where the extreme sits is a separate skill, so they get their own page — pandas max, min, idxmax and idxmin.

The arguments that earn their keep

All six take the same three:

df.mean(axis='index',        # down each column (default), or across each row
        skipna=True,         # drop missing values, or let one NaN swallow the answer
        numeric_only=False)  # every column, or only the numeric ones

axis is the one people forget. The default runs down each column and gives you one answer per column. Pass axis='columns' and it runs across each row, which is a completely different question — and a common one whenever your columns are comparable measurements rather than different variables.

skipna=True means a missing value is not an error, it is just absent. Set it to False and a single hole anywhere makes the whole answer NaN. That sounds useless. It is actually the fastest way to find out whether the column you just averaged had any holes at all.

numeric_only=False is the default, which means these methods do consider your text columns. Here the family splits. In Pandas 3, .mean(), .median() and .std() refuse and raise a TypeError; .sum(), .min() and .max() go ahead and give you an answer, because concatenating and alphabetizing are things you can do to strings. Pass numeric_only=True whenever you mean the numbers.

Two more that belong to individual members. .std() takes ddof=1 by default, so you get the sample standard deviation; pass ddof=0 if you want the population one. And .sum() takes min_count, which is the antidote to the third mistake below.

A worked example, on real data

Here is IBTrACS, the archive behind Bamboo Weekly #142: every North Atlantic tropical cyclone from 1851 to 2025, one row per observation of a storm, taken a few hours apart along its track. Several agencies track the same storms, so the file has one wind column per agency.

import pandas as pd

url = ('https://www.bambooweekly.com/content/files/2025/10/'
       'bw-142-ibtracs.csv')

storms = pd.read_csv(url, skiprows=[1], skipinitialspace=True,
                     usecols=['SID', 'SEASON', 'NAME', 'NATURE', 'WMO_WIND',
                              'WMO_PRES', 'USA_WIND', 'STORM_SPEED',
                              'TOKYO_WIND'])

storms.shape
(127619, 9)

skiprows=[1] drops the units row that IBTrACS puts under its header, and skipinitialspace=True matters more than it looks: without it the empty cells arrive as strings of spaces, and every numeric column comes in as text.

Now, what is the average storm? Ask the obvious way:

storms[['WMO_WIND', 'WMO_PRES', 'USA_WIND', 'STORM_SPEED']].mean()
WMO_WIND        52.579249
WMO_PRES       992.343510
USA_WIND        52.795970
STORM_SPEED     11.747051
dtype: float64

Four tidy numbers, and every one of them is computed over a different number of rows. You cannot tell that from the output. So ask for both at once, which is what agg is for:

storms[['WMO_WIND', 'WMO_PRES', 'USA_WIND', 'STORM_SPEED']].agg(['count', 'mean'])
           WMO_WIND     WMO_PRES      USA_WIND    STORM_SPEED
count  55458.000000  24369.00000  108479.00000  127599.000000
mean      52.579249    992.34351      52.79597      11.747051

Out of 127,619 rows, the WMO wind figure rests on 55,458 of them and the WMO pressure figure on 24,369 — nineteen percent. Those two numbers sit side by side in the same output, look equally solid, and describe samples that differ by a factor of two. Adding count costs you one word and buys you the denominator.

The whole family reads the same way, and one call gets you all of it:

storms['USA_WIND'].agg(['count', 'min', 'median', 'mean', 'std', 'max'])
count     108479.000000
min           10.000000
median        45.000000
mean          52.795970
std           24.502655
max          165.000000
Name: USA_WIND, dtype: float64

Mean 52.8 knots against a median of 45. The mean sits well above the middle, which tells you there is a long tail of powerful storms pulling it up. That gap is one of the most useful two-second checks in data analysis, and it is why I rarely report a mean on its own.

Turn the axis and you ask a different question. Two agencies estimate the wind for the same storm at the same moment, and the file records both, so averaging across the row averages the agencies:

storms.loc[100000:100004, ['WMO_WIND', 'USA_WIND']]
        WMO_WIND  USA_WIND
100000     120.0     120.0
100001       NaN     120.0
100002     120.0     120.0
100003       NaN     120.0
100004     120.0     120.0
storms.loc[100000:100004, ['WMO_WIND', 'USA_WIND']].mean(axis='columns')
100000    120.0
100001    120.0
100002    120.0
100003    120.0
100004    120.0
dtype: float64

Hurricane Cindy in 1999, at a steady 120 knots. But look again at rows 100001 and 100003: those consensus figures average one agency, not two, because the WMO reports every six hours while the file records every three. Same output, two different denominators, no warning. skipna=False is the fastest way to see it:

storms.loc[100000:100004, ['WMO_WIND', 'USA_WIND']].mean(axis='columns', skipna=False)
100000    120.0
100001      NaN
100002    120.0
100003      NaN
100004    120.0
dtype: float64

And the whole story lands in a single table once you group. NATURE records what kind of system each observation was — TS for tropical, ET for extratropical, DS for disturbance, SS for subtropical, MX where the agencies disagreed, NR for not reported:

storms.groupby('NATURE')['USA_WIND'].agg(['size', 'count', 'mean', 'median',
                                          'std', 'max', 'sum'])
          size  count       mean  median        std    max        sum
NATURE
DS        4490   4436  26.512849    25.0   6.641747   80.0   117611.0
ET       13169  11529  45.170873    45.0  13.784575  105.0   520775.0
MX         610    610  51.772131    50.0  18.998199  100.0    31581.0
NR         170      0        NaN     NaN        NaN    NaN         0.0
SS        2359   2092  39.652964    40.0  10.989932   75.0    82954.0
TS      106821  89812  55.386062    50.0  25.318298  165.0  4974333.0

Tropical systems average 55 knots and reach 165; disturbances average 27 and are tightly clustered, with a standard deviation of 6.6 against the tropical 25. And then there is the NR row: 170 observations, zero of which carry a wind figure. Mean, median, standard deviation and max all correctly say NaN. sum says 0.0.

Three mistakes people make

Averaging an average. This is the big one, and it is the one that produces wrong numbers rather than merely fragile ones. Storms are tracked for wildly different lengths of time, so a per-storm average gives every storm one vote no matter how long it lasted:

per_storm = storms.groupby('SID')['USA_WIND'].agg(['count', 'mean']).dropna()

per_storm['count'].agg(['min', 'median', 'max'])
min         1.0
median     45.0
max       264.0
Name: count, dtype: float64

One storm contributed a single observation; another contributed 264. Now take the average of those 2,019 per-storm averages, and compare it against the average over the raw observations:

storms['USA_WIND'].mean()          # 52.795969726859575
per_storm['mean'].mean()           # 48.072919996694026

Nearly five knots apart, and both are "the average wind speed." The mean of the means is dragged down because storm length and storm strength go together — the correlation between a storm's observation count and its mean wind is 0.44. The 219 storms with at least 100 observations average 61.0 knots; the 852 with fewer than 40 average 41.2. Give every storm one vote and the long, strong ones are outnumbered almost four to one.

The fix is to weight each group mean by its own count, which recovers the original figure exactly:

(per_storm['mean'] * per_storm['count']).sum() / per_storm['count'].sum()
52.795969726859575

Whenever your groups differ in size — and they always do — taking .mean() of a column of group means is not the overall mean. Either weight it, or compute it from the ungrouped data.

Reading a mean without its count. The same trap in slow motion, across time. Group the WMO wind figure by decade and ask for the row count and the value count side by side:

(
    storms
    .assign(decade=lambda df_: df_['SEASON'] // 10 * 10)
    .groupby('decade')
    .agg(rows=('SEASON', 'size'),
         values=('WMO_WIND', 'count'),
         mean_wind=('WMO_WIND', 'mean'))
    .iloc[::4]
)
         rows  values  mean_wind
decade
1850     5529     809  71.112485
1890     8073    2857  62.219111
1930     6472    3308  53.616989
1970     8733    4432  40.091381
2010    10022    5181  48.922023

Read the mean_wind column alone and the Atlantic has been calming down since the 1850s, dramatically. Read the values column next to it and the claim evaporates: the 1850s figure rests on 809 observations out of 5,529 rows, and whatever process decided which observations got a wind figure in 1855 was not the process operating in 2015. The two means describe samples that differ in size and in kind. Neither number is wrong; the comparison is.

Forgetting that .sum() does not behave like the other five. Every reducer here skips missing values, but sum is the only one with a defined answer for a column that has nothing in it at all: it returns 0.0 where mean, median and the rest return NaN. It will also add up a column of text rather than refusing, where .mean() raises. Both of those are worth knowing before you trust a total, and both are on the sum page.

Where it shows up in Bamboo Weekly

Bamboo Weekly #33: Fracking is the one to read first, because it is built entirely on the mean-versus-median gap. It opens with my favorite data-analysis joke — Bill Gates walks into a bar, and on average everyone in the bar is now a millionaire — then runs .agg(['median', 'mean']) on water use per well by county and sorts the counties by the difference between the two. Colorado's Elbert County comes out top, with a mean 8.7 million gallons above its median. It is also a good illustration of the denominator problem, because the solution starts by measuring it: the water column it averages is missing in about one percent of the rows, which the post finds with df.isna().sum() before computing anything.

Bamboo Weekly #28: Pret A Manger is axis='columns' doing real work. The columns are shopping districts, some in London and some not, so a filter on the column names followed by .mean(axis='columns') builds two summary series inside one assign. The last row compares them: 108.6 for London against 102.4 elsewhere.

Bamboo Weekly #57: International arms trade is groupby(...).sum() at its most direct — total deliveries per supplier, then nlargest on the result, which puts the United States at 11,287 against Germany's 3,287. It then groups by year and supplier together and unstacks, the usual next move once a grouped sum is in hand.

Bamboo Weekly #60: Iceland uses .std() the way it most often gets used in practice — not as a reported statistic but as a filter. One .loc[] keeps only the Airbnb listings priced below the mean plus two standard deviations, dropping the outliers before any of the actual analysis begins.

Practice it

Work through a mean exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/mean/

Go deeper

agg is the method that makes this family usable, because it lets you ask for several reducers in one pass and puts the count right next to the mean, and count explains why count and size disagree. groupby is where these methods do most of their work, and pivot_table is the same aggregation arranged as a grid. For the extremes and where they sit, see max, min, idxmax and idxmin; for everything at once, describe runs most of this family for you. And when the holes themselves are the problem rather than something to skip past, fillna and dropna are the decision you are actually making — skipna=True just makes it for you, quietly.

More Pandas videos on Python and Pandas with Reuven Lerner.

Bamboo Weekly is the practice. If you want the structured version — full courses with downloadable Jupyter notebooks, plus live Pandas office hours when you get stuck — that is LernerPython+Data. A paid Bamboo Weekly subscription is included with it.

See it on real data

Below are the 88 Bamboo Weekly exercises that use mean on real-world data — try each one, then study the worked solution.

Part of the Pandas Methods Index. See also practice by skill.