The middle value — and the number to report when the mean is lying to you.
What does a typical one look like? People reach for .mean() to answer that, and on skewed data the mean answers a different question. The mean is the balance point of the values; the median is the middle of the queue, with as many observations above it as below. When the two are far apart, the median is almost always the one describing something a reader would recognize.
.median() shares its keyword arguments with the rest of the reducers, and those are covered on mean. This page is about when to reach for it, because that is the part that costs people money and credibility.
Official documentation: DataFrame.median and Series.median.
A worked example, on real data
Here is the NACUBO-Commonfund Study of Endowments, the workbook behind Bamboo Weekly #158. One row per institution, 678 of them, with the endowment converted from thousands of dollars into millions:
import pandas as pd
url = ('https://www.bambooweekly.com/content/files/2026/02/bw-158-2025-NCSE-'
'Endowment-Market-Values-for-US-and-Canadian-Institutions-FINAL.xlsx')
endow = (
pd.read_excel(url, skiprows=4, nrows=678,
usecols=['Institution Name', 'State',
'IPEDS Institution Sector2',
'FY25 Total Endowment Market Value (in $1,000s)'])
.rename(columns={'Institution Name': 'name',
'IPEDS Institution Sector2': 'sector',
'FY25 Total Endowment Market Value (in $1,000s)': 'endowment'})
.assign(endowment=lambda df_: df_['endowment'] / 1_000)
)
endow['endowment'].agg(['count', 'mean', 'median', 'max']).round(1)
count 678.0
mean 1406.6
median 259.9
max 55670.3
Name: endowment, dtype: float64
The average American university endowment is 1.4 billion dollars. The median is 260 million. Those two sentences describe the same 678 schools and they do not describe the same world, and the reason is sitting in the max row: Harvard's 55.7 billion, with Yale, Stanford and Princeton right behind it.
Here is the number that settles the argument:
(endow['endowment'] < endow['endowment'].mean()).mean()
0.8244837758112095
Eighty-two percent of these institutions have an endowment below the average endowment. A statistic that four out of five members of the group fail to reach is not describing the group. The median, by construction, has half above and half below.
The other thing the median does is refuse to move. Delete the ten richest institutions — $320 billion, a third of all the money in the file — and watch:
(
endow
.sort_values('endowment', ascending=False)
.iloc[10:]
['endowment']
.agg(['mean', 'median'])
.round(1)
)
mean 948.7
median 253.1
Name: endowment, dtype: float64
The mean drops by a third. The median moves by under seven million dollars, less than three percent. That is the whole property: the median counts how many values sit above it, not how far above.
Report both together and the shape of each group comes through:
endow.groupby('sector')['endowment'].agg(['count', 'mean', 'median']).round(1)
count mean median
sector
4-Year Private Nonprofit College/University 363 1704.6 295.7
4-Year Public College/University 242 905.4 227.8
Public 2-Year College 17 37.8 29.1
State System Office 16 5390.1 1858.1
Private four-year schools show a mean nearly six times their median, which is the Harvard effect again. Community colleges show the two within nine million of each other, because there is no long tail there at all — that is what an unskewed column looks like. The gap between the statistics is itself the diagnostic, and it costs one extra word inside agg.
Three mistakes people make
Reporting the mean because it is the habit. The two numbers here differ by a factor of 5.4, and only one of them can be put in a sentence beginning "a typical university." If you catch yourself writing "average" about money, population, page views, response times or anything else with a floor at zero and no ceiling, check the median before you publish. The check is .agg(['mean', 'median']), and it takes a second.
Expecting the median to be a value from your data. With 678 institutions, Pandas averages the 339th and 340th:
endow['endowment'].median()
259.86
(endow['endowment'] == 259.86).any()
False
No school has an endowment of $259.86 million. Bowling Green State has $259.22 million, Suffolk University has $260.50 million, and the median is the point between. Fine as a summary statistic, wrong as a label: you cannot name "the median institution" unless your count is odd.
Losing rows to a groupby you did not check. The sector table above covers 638 institutions, not 678, because 40 rows have no IPEDS sector recorded and groupby drops missing keys silently. The medians in it are all correct; the table is just not about everybody. Compare len(df) against the sum of the count column whenever a grouped summary is going into a report. And note that .median() on the frame as a whole raises TypeError: Cannot perform reduction 'median' with string dtype — unlike .min(), it will not quietly do something strange to your text columns. Pass numeric_only=True.
Where it shows up in Bamboo Weekly
Bamboo Weekly #49: Campaign finance has the widest mean-versus-median gap of any Bamboo Weekly I have written. Grouping FEC filings into national and state races and running .agg(['mean', 'median']) on total receipts gives national candidates a mean of $3,470,216 against a median of $5,892.50 — a factor of 589, because a handful of presidential campaigns share a column with thousands of hopefuls who raised almost nothing. Free to read.
Bamboo Weekly #24: Wildfire smoke explains the choice out loud. Plotting daily PM2.5 by state, I used pivot_table(..., aggfunc='median') rather than the mean precisely so that one extreme monitoring station could not move a whole state's line. Free to read.
Bamboo Weekly #69: Election participation groups countries by whether voting is compulsory and reports both statistics: mean invalid-ballot rates of 2.91 and 5.93 percent, medians of 1.80 and 3.86. The gap survives either way, which is exactly the reassurance you want before making a claim. Free to read.
Bamboo Weekly #72: City travel runs .agg(['mean', 'median']) across three columns at once and then sorts on the tuple ('Car', 'median') to rank countries by car dependence. Median car use in the United States comes out just under 97 percent, against Italy's 61. Free to read.
Practice it
Work through a median exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/median/
Go deeper
mean is the other half of the comparison, and agg is how you get both in one pass. The median is the 50th percentile, so quantile generalizes it to any cut point you like, and describe prints it as the 50% row along with the quartiles on either side. When a picture would say it faster than the gap between two numbers, that is px.histogram and friends.
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.
Related methods
.mean()— when the data is symmetric enough that the average is honest.quantile()— when you want a percentile other than the middle one