pandas describe() — profile any column in one call

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.

What it does

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.

Why it matters

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().

Examples

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.

Flags

FlagMeaning
(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().TIdiom: transpose so each column becomes a row - readable on 50-column frames.

Born parameterless in 0.4.0 (September 2011)

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'.

One method, three personalities

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.

No C accelerator - just dtype dispatch

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.

Fun facts

Pros

Cons

Takeaways