Knowledge base / Python
pandas describe(): summary statistics for a table and their pitfalls
I show how I read describe() output for numbers, text, categories and dates in pandas 3, why include='object' now raises a warning and what this small table will never tell me.

describe() is the first thing I run after loading a new table in pandas, right after info(). info() tells me which columns and types I have. describe() shows in one small table what the values themselves look like: how many there are, the mean, the spread, the minimum, the maximum and the quartiles. For text it counts distinct values and names the most frequent one.
I ran every example in Python 3.14 with pandas 3.0.6, and the output below is copied from that run. The version matters here. pandas 3 changed the default text type and the behaviour of percentiles, so some older tutorials show things that work differently today. A wider example of working with a table, from a variable to a checked report, is in the article Python basics: from a variable to a checked pandas report.
describe() syntax
DataFrame.describe(percentiles=None, include=None, exclude=None)
percentiles: a list of fractions between 0 and 1, for example[0.1, 0.5, 0.9]. By default I get 25%, 50% and 75%.include: the column types to describe, for example"number","str","category","datetime", a list of such types or"all".exclude: the column types to leave out, in the same format.
Without arguments DataFrame.describe() covers numeric and date columns. It skips text, categories and Booleans. The exception: if the DataFrame has no numeric and no date column at all, it describes text, categories and Booleans. A single column (Series) has the same method. There include and exclude are ignored, because the output depends on the type of that one column.
Numbers: count, mean, std and quartiles
I work on a small, synthetic table of online shop orders. It has every type I discuss below: an ID and a sales channel as text, the delivery method as a category, the order date, the number of items and the amount.
# example: data
import pandas as pd
orders = pd.DataFrame({
"order_id": ["A-101", "A-102", "A-103", "A-104", "A-105", "A-106", "A-107", "A-108"],
"channel": ["web", "store", "web", "app", "web", None, "store", "web"],
"delivery": pd.Categorical(
["locker", "courier", "locker", "pickup", "locker", "courier", "locker", "locker"],
categories=["courier", "locker", "pickup", "post"],
),
"ordered_at": pd.to_datetime([
"2026-03-02", "2026-03-02", "2026-03-04", "2026-03-09",
"2026-03-11", "2026-03-15", "2026-03-20", "2026-03-31",
]),
"items": [1, 3, 2, 1, 2, 4, 1, 2],
"amount": [48.0, 120.0, 76.0, 30.0, 88.0, 410.0, 52.0, 64.0],
})
On a DataFrame with only numbers a plain describe() would be enough. Mine also has dates, so I start by narrowing the output to numbers. round(2) only shortens the printout.
# example: numbers
print(orders.describe(include="number").round(2))
items amount
count 8.00 8.0
mean 2.00 111.0
std 1.07 123.9
min 1.00 30.0
25% 1.00 51.0
50% 2.00 70.0
75% 2.25 96.0
max 4.00 410.0
This is how I read it:
countis the number of non-missing values. Here it is 8 for 8 rows, so nothing is missing. describe does not print the number of missing values, so I compare count withlen(orders)or count them with isna().mean(111) and50%, the median (70), are far apart. The mean is even above the third quartile (96). That is my first hint that one large value pulls the result up.minandmaxare the extremes, and25%,50%and75%are the quartiles.
pandas computes quartiles with linear interpolation. For eight amounts the position of the first quartile is (8 - 1) × 0.25 = 1.75. The result lies three quarters of the way between the second (48) and the third (52) sorted amount: 48 + 0.75 × 4 = 51. That is why an integer column such as items can have a quartile of 2.25.
std is the sample standard deviation. pandas divides by n - 1 (ddof=1), the same as STDEV.S in Excel. NumPy’s np.std divides by n by default, so when I compare numbers from two tools, I check which variant is behind each of them:
# example: ddof
print(round(orders["amount"].std(), 2))
print(round(orders["amount"].std(ddof=0), 2))
123.9
115.9
With a single value in a column, std is NaN, because n - 1 = 0.
Text, categories and dates in pandas 3
In pandas 3 text gets the str type by default instead of object. So I describe text columns with include="str":
# example: text
print(orders.describe(include="str"))
order_id channel
count 8 7
unique 8 3
top A-101 web
freq 1 4
countforchannelis 7 with eight rows. TheNonein the data was stored as a missing value and is not counted.uniqueis the number of distinct non-missing values.topis the most frequent value andfreqis how many times it occurs. The web channel appears 4 times.- For
order_id, count and unique are equal and freq is 1. Every ID occurs once, so this column has no duplicates. In that case top (A-101) means nothing; it is just one of the values that occurred once.
When there is a tie, the documentation says plainly that top is chosen arbitrarily among the most frequent values. The full distribution is what value_counts() is for.
Older tutorials use include="object". In pandas 3.0.6 it still returns the text columns, but it raises a Pandas4Warning: str columns are matched by object only for backward compatibility, and that shortcut will be removed. When code has to run on both pandas 2 and 3, the migration guide recommends the list ["object", "string"]. I checked this in pandas 2.3.3: include="str" fails there with a TypeError, while the list works in both versions.
# example: text-compatible
text_summary = orders.describe(include=["object", "string"])
Categories get the same four rows:
# example: category
print(orders.describe(include="category"))
delivery
count 8
unique 3
top locker
freq 5
unique counts the values that actually occur. The post category is defined, but no order uses it, so unique is 3, not 4. Boolean (True/False) columns are described the same way as categories.
describe treats dates much like numbers: it gives the mean, the minimum, the quartiles and the maximum, without std.
# example: dates
print(orders["ordered_at"].describe())
count 8
mean 2026-03-11 18:00:00
min 2026-03-02 00:00:00
25% 2026-03-03 12:00:00
50% 2026-03-10 00:00:00
75% 2026-03-16 06:00:00
max 2026-03-31 00:00:00
Name: ordered_at, dtype: object
The mean date, 11 March at 18:00, is computed correctly, but I rarely need it. With dates I mostly look at min and max, which is the range I actually received. Because dates count as numbers here, a plain orders.describe() (since pandas 2.0) shows numbers and dates together. The std row then moves to the bottom of the table, with NaN in the date column.
include="all" puts every type into one table. Rows that do not fit a column’s type get NaN:
# example: all
print(orders[["channel", "amount"]].describe(include="all").round(2))
channel amount
count 7 8.0
unique 3 NaN
top web NaN
freq 4 NaN
mean NaN 111.0
std NaN 123.9
min NaN 30.0
25% NaN 51.0
50% NaN 70.0
75% NaN 96.0
max NaN 410.0
exclude works the other way round. orders.describe(exclude=["number", "datetime"]) describes the ID, the channel and the delivery method, so everything except numbers and dates.
Custom percentiles
I can replace the default quartiles with any percentiles. I pass them as fractions: 0.9 means 90%, and passing 90 ends with a ValueError.
# example: percentiles
print(orders["amount"].describe(percentiles=[0.1, 0.5, 0.9]).round(2))
count 8.0
mean 111.0
std 123.9
min 30.0
10% 42.6
50% 70.0
90% 207.0
max 410.0
Name: amount, dtype: float64
In pandas 3 I get exactly the percentiles I ask for. Up to version 2.x describe always added the median. Since pandas 3.0, percentiles=[0.1, 0.9] returns only 10% and 90%, so when I need the median, I add 0.5 myself. The output also shows another trace of the large order: 90% sits at 207, while the maximum is 410.
What describe does not tell you

describe() summarises every column with a handful of numbers. That is convenient, but a summary drops detail, and it helps to know which detail.
The shape of the distribution. Two different datasets can have a similar mean and similar quartiles even though one has two clusters of values and the other has one. The table cannot tell them apart. For an important column I look at a histogram or at bins from value_counts(bins=...).
Outliers. describe shows the symptoms: a mean above the third quartile and a maximum far from 75%. It does not point to the row. I find that myself, for example with nlargest():
# example: outlier
print(orders.nlargest(2, "amount")[["order_id", "amount"]])
order_id amount
5 A-106 410.0
1 A-102 120.0
Whether 410 is an error or a large business order, describe cannot decide. Here I have to go back to the source of the data.
Missing values written as text. I consider this case the most dangerous. One word in a column of amounts is enough to turn the whole column into text. describe raises no error then. It quietly returns text statistics. In the synthetic export below someone typed brak (Polish for “none”) instead of leaving the cell empty. In your files it may be “-” or “unknown”; the effect is the same.
# example: hidden-missing
import io
csv = io.StringIO(
"order_id,amount\n"
"A-201,48\n"
"A-202,120\n"
"A-203,brak\n"
"A-204,\n"
"A-205,88\n"
)
raw = pd.read_csv(csv)
print(raw.dtypes)
print(raw.describe())
order_id str
amount str
dtype: object
order_id amount
count 5 4
unique 5 4
top A-201 48
freq 1 1
The amount column has the str type. A count of 4 includes the word brak, and the top value 48 is text. read_csv() recognised the empty cell as missing, and it would do the same with “n/a”, “NA” or “NULL”. brak is not on that list. So I prefer to tell pandas explicitly what a missing value looks like:
# example: hidden-missing-fix
csv.seek(0)
clean = pd.read_csv(csv, na_values=["brak"])
print(clean["amount"].describe().round(2))
print(clean["amount"].isna().sum())
count 3.00
mean 85.33
std 36.07
min 48.00
25% 68.00
50% 88.00
75% 104.00
max 120.00
Name: amount, dtype: float64
2
Now count is 3 and isna() shows two missing values. Another route is to_numeric(errors=“coerce”). It turns every value that is not a number into NaN, including an amount written with a decimal comma. With that route I always check how many values disappeared.
Common mistakes
Selecting statistics as if they were columns. orders.describe()[["mean", "std"]] ends with a KeyError. In the describe output the statistics are rows, and the table’s columns stay columns. I select rows with .loc:
# example: select-rows
summary = orders.describe(include="number")
print(summary.loc[["mean", "50%", "std"]].round(2))
items amount
mean 2.00 111.0
50% 2.00 70.0
std 1.07 123.9
With many columns summary.T, which swaps rows and columns, is often easier to read.
Averaging IDs. If an order number or a postcode is stored as a number, describe calculates its mean and standard deviation. I convert such columns to text with astype() or leave them out of the summary.
Copying include="object" into new code. In pandas 3 I write include="str", and in code for both versions include=["object", "string"].
Expecting the median from custom percentiles. When I pass percentiles in pandas 3, I add 0.5, otherwise there is no 50% row.
A short exercise
Add a ninth order to orders whose amount is the text brak, for example with pd.concat. Run orders.describe(include="number") and check what happened to the amount column. Then replace brak with a real missing value and restore the numeric type with pd.to_numeric(orders["amount"], errors="coerce"), because the replacement alone leaves the column as text. Compare count with the number of rows. Finally compute the 0.05 and 0.95 percentiles and see whether order A-106 still clearly stands out.
Key takeaways
Why does describe() skip my text columns?
When a DataFrame has numeric columns, describe() without arguments covers only numbers and dates. In pandas 3 I get text with include='str', categories with include='category' and every column at once with include='all'.
Is the std in describe() the sample or the population standard deviation?
The sample one. pandas divides by n - 1 (ddof=1), like STDEV.S in Excel. NumPy's np.std divides by n by default, so it returns a smaller number.
How do I pick only the mean and the median from describe()?
The statistics are rows of the result, so I select them with .loc, for example df.describe().loc[['mean', '50%']]. Writing df.describe()[['mean']] looks for a column with that name and ends with a KeyError.
