One call gives you count, mean, std and quartiles — know the shape before you touch the data.
Six rows of output and you already know where the outliers live — describe() is the cheapest EDA you will ever run.
describe() collapses a Series or DataFrame into a small summary table, NaNs excluded. Numeric columns get count, mean, std, min, 25%/50%/75%, max. Object columns get a different set: count, unique, top (the mode), freq. Datetime columns get a mean timestamp plus min/max quartiles. percentiles=[...] swaps the default quartiles for your own cut points; include/exclude widen or narrow which dtypes get profiled.
Before any aggregation, join or model you want the shape of the data: is revenue skewed, are there 4 regions or 40, are delivery dates clustered or spread? describe() answers that in one line instead of five separate calls, and percentiles=[.05, .95] turns it into an instant outlier fence. It is the second call I make on any new dataset, right after info().
import pandas as pd
df = pd.DataFrame({
'region': ['North', 'North', 'South', 'South', 'East', 'East', 'West', 'West'],
'units': [120, 85, 240, 95, 60, 175, 310, 45],
'revenue': [24000, 17000, 48000, 19000, 12000, 35000, 62000, 9000],
})
print(df['revenue'].describe(percentiles=[.1, .9]))count 8.000000 mean 28250.000000 std 18668.154703 min 9000.000000 10% 11100.000000 90% 52200.000000 max 62000.000000 Name: revenue, dtype: float64
Passing percentiles replaces the default 25/50/75 set entirely — no automatic median row. The 10% cut sits at 11,100 EUR: 90% of months brought in more.
print(df.describe(include='all'))
region units revenue count 8 8.000000 8.000000 unique 4 NaN NaN top North NaN NaN freq 2 NaN NaN mean NaN 141.250000 28250.000000 std NaN 93.340774 18668.154703 min NaN 45.000000 9000.000000 25% NaN 78.750000 15750.000000 50% NaN 107.500000 21500.000000 75% NaN 191.250000 38250.000000 max NaN 310.000000 62000.000000
include='all' is the mixed-frame move: the string column gets count/unique/top/freq, numbers keep their stats, and the union index fills gaps with NaN.
dts = pd.DataFrame({
'order': [1001, 1002, 1003, 1004, 1005, 1006],
'delivered': pd.to_datetime([
'2026-08-03', '2026-08-11', '2026-08-19',
'2026-09-02', '2026-09-15', '2026-09-24',
]),
})
print(dts['delivered'].describe())count 6 mean 2026-08-27 20:00:00 min 2026-08-03 00:00:00 25% 2026-08-13 00:00:00 50% 2026-08-26 00:00:00 75% 2026-09-11 18:00:00 max 2026-09-24 00:00:00 Name: delivered, dtype: object
Datetime columns get their own five-stat set — mean included, no std. The mean timestamp is real math on nanoseconds, which is why it lands on 20:00.
s = pd.Series([19.949, 42.0, 7.5, 19.949, 88.25, 3.2], name='cart_eur') print(s.describe().round(2))
count 6.00 mean 30.14 std 31.51 min 3.20 25% 10.61 50% 19.95 75% 36.49 max 88.25 Name: cart_eur, dtype: float64
describe() returns a regular Series, so .round(2) chains straight onto it — the same trick works with .T on wide DataFrame output.
| Flag | Meaning |
|---|---|
(no args) | Numeric columns only: count, mean, std, min, 25%/50%/75%, max — silently drops text and dates. |
percentiles=[.05, .95] | Replaces the default quartiles with your cut points, in 0-1; the median is not force-added. |
include='all' | Profile every column: strings contribute count/unique/top/freq, the index is the union. |
exclude='number' | The flip side - profile only the non-numeric side (select_dtypes-style: numpy.number, 'O'). |
include=['category'] | List dtypes to whitelist; categoricals report counts for every category, even unused ones. |
Series.describe() | Same stats for one column; the Series .name carries into the output row labels. |
.describe().T | Idiom: transpose so each column becomes a row - readable on 50-column frames. |
Early describe() took zero arguments and hardcoded the quantiles at 10%, 50% and 90% - the docs only promised it 'for floating point data'. Configurability arrived in stages: percentile_width deprecated in 0.14.0 (May 2014), percentiles and include/exclude added in 0.14.1-0.15.0, percentile_width removed in 0.17.0 (October 2015). Along the way, 0.16.0 (March 2015) deleted the overlapping top-level value_range function 'in favor of describe'.
Since 0.15.0 describe() lives on the NDFrame base class and dispatches by dtype: numeric, categorical and timestamp columns each get their own stat set. In pandas 3.x the dispatcher is describe_ndframe in pandas/core/methods/describe.py, backed by describe_numeric_1d, describe_categorical_1d and describe_timestamp_1d - a method whose output literally depends on what you feed it.
describe() is not numexpr or C-fast-path magic; it is plain composition. pandas/core/methods/describe.py routes each column through select_describe_func into describe_numeric_1d, describe_categorical_1d or describe_timestamp_1d, each a handful of scalar reductions (count, mean, quantile, ...). _refine_percentiles first clamps, dedupes and sorts your percentiles. Cost is one pass per statistic, which is why describe() on a 100M-row frame takes seconds, not milliseconds - and why df.agg(['count', 'mean']) beats it when you only need two of the numbers.