← All articles
StatisticsPython

Descriptive Statistics in Python: A Practical Guide to Center and Spread

A practical walkthrough of descriptive statistics in Python: measures of center, measures of spread, and percentiles, computed with pandas and NumPy on a real restaurant tipping dataset.

Before you fit a model or run a test, you should be able to answer a much smaller question: what does this column of numbers actually look like? Descriptive statistics is the toolkit for that — a handful of numbers that summarize hundreds or millions of rows into something a human can reason about. It’s the step that comes before everything else, including the workflow in our first machine learning model post, where exploring the data honestly is what made the model trustworthy in the first place.

The trouble is that “summarize this column” has more than one right answer, and the different answers can disagree in ways that quietly mislead you. A single average can hide a skewed distribution. A standard deviation computed the wrong way can be subtly off. This guide gives you one mental model for organizing the whole topic, then works through it hands-on with pandas and NumPy on a dataset you can reproduce exactly.

The Mental Model: Center, Spread, and Position

Every descriptive statistic answers one of three questions about a column of numbers:

  1. Where’s the center? — what value is “typical” (mean, median, mode).
  2. How spread out is it? — how much do values vary around that center (variance, standard deviation, range, IQR).
  3. Where does a specific value fall? — its position relative to everything else (percentiles and quantiles).

Center alone can lie. “Average tip: $3.00” sounds like a single, settled fact, but it hides whether most tips cluster tightly around $3 or swing wildly between $1 and $10. That’s why center and spread are always reported together — spread is what tells you how much to trust the center. Percentiles then let you locate any individual value inside that shape, which is exactly what “spread” was describing in the first place.

Keep those three questions in mind. Every method in this post answers one of them.

A Dataset You Can Reproduce

Restaurant tipping is a good subject for descriptive statistics because the numbers aren’t tidy — most bills cluster in a normal range, but a handful of big parties and big spenders stretch the top end. That’s exactly the kind of shape where mean and median start to disagree, which makes the difference between them concrete instead of theoretical.

We’ll use the tips dataset — 244 restaurant bills with the tip amount, party size, day, and time — which ships with seaborn as a sample dataset for exactly this kind of teaching.

import pandas as pd
import numpy as np
import seaborn as sns

tips = sns.load_dataset("tips")
tips.shape
(244, 7)

Data: the tips dataset (BSD-3-Clause), bundled with the seaborn library — a classic restaurant-tipping sample used throughout data science teaching, no download required beyond pip install seaborn.

tips.head()
   total_bill   tip     sex smoker  day    time  size
0       16.99  1.01  Female     No  Sun  Dinner     2
1       10.34  1.66    Male     No  Sun  Dinner     3
2       21.01  3.50    Male     No  Sun  Dinner     3
3       23.68  3.31    Male     No  Sun  Dinner     2
4       24.59  3.61  Female     No  Sun  Dinner     4

Seven columns, 244 rows. We’ll spend most of this post on total_bill. (The outputs in this post come from pandas 3.0 — everything shown also works on pandas 2.x.)

Measures of Center: Mean, Median, and Mode

The mean is the sum divided by the count — the number most people mean by “average”:

tips["total_bill"].mean()
19.78594262295082

The median is the middle value once everything is sorted — half the bills are below it, half above:

tips["total_bill"].median()
17.795

Notice those two numbers don’t agree. The mean ($19.79) sits noticeably above the median ($17.80). That gap is the signature of a right-skewed distribution: most bills are modest, but a long tail of expensive checks — the largest is $50.81 — pulls the mean upward without moving the median much at all.

print(f"mean:   {tips['total_bill'].mean():.2f}")
print(f"median: {tips['total_bill'].median():.2f}")
print(f"max:    {tips['total_bill'].max():.2f}")
mean:   19.79
median: 17.80
max:    50.81
Bar chart of total_bill values from the tips dataset grouped into $5 bins, showing a right-skewed distribution peaking between $10 and $20 with a long tail toward $55, with the median marked at $17.80 and the mean marked further right at $19.79 because the tail pulls it toward the larger bills.

Read the shape, not just the numbers: the tallest bars sit between $10 and $25, and the bars keep thinning out all the way to $50 instead of stopping abruptly. The median sits inside the tall part of the distribution, where most bills actually are. The mean sits to its right, dragged there by the thinning tail — which is exactly why “average” and “typical” aren’t always the same word.

The mode is the single most frequent value. For continuous data like dollar amounts, it’s often a coincidence of rounding rather than a meaningful summary:

tips["total_bill"].mode()
0    13.42
Name: total_bill, dtype: float64

$13.42 shows up three times purely by chance — nothing about it is special. Mode earns its keep on discrete columns instead, where repeated values are the norm rather than a fluke:

tips["size"].mode()
0    2
Name: size, dtype: int64

A party of 2 is the single most common size in the dataset — that’s a genuinely useful fact, unlike the $13.42 coincidence above.

Measures of Spread: Variance, Standard Deviation, and IQR

Center tells you where the middle is; spread tells you how far values wander from it. The variance is the average squared distance from the mean, and the standard deviation is its square root — back in the original dollar units, which is why std is the one people actually quote:

tips["total_bill"].var()
tips["total_bill"].std()
79.25293861397827
8.902411954856856

A standard deviation of about $8.90 means bills typically land somewhere in the neighborhood of $19.79 ± $8.90 — roughly $11 to $29 — though that rule of thumb assumes a fairly symmetric distribution, which we already know total_bill isn’t quite.

The range is the crudest spread measure — max minus min, sensitive to a single extreme value. The interquartile range (IQR) is sturdier: the width of the middle 50% of the data, from the 25th percentile (Q1) to the 75th (Q3), ignoring both tails entirely:

bill_range = tips["total_bill"].max() - tips["total_bill"].min()
q1 = tips["total_bill"].quantile(0.25)
q3 = tips["total_bill"].quantile(0.75)
iqr = q3 - q1
bill_range, q1, q3, iqr
(47.74, 13.3475, 24.127499999999998, 10.779999999999998)

The full range spans nearly $48, but the middle half of all bills is squeezed into a $10.78 band from about $13.35 to $24.13. That contrast is the point of the IQR: it tells you where the bulk of ordinary bills sit, undistracted by the few outliers that stretch the range.

Percentiles and Quantiles: Locating Any Value

A percentile answers “what value is this observation’s rank equivalent to, out of 100?” The 90th percentile is the value below which 90% of the data falls. NumPy’s percentile takes a list of percentiles as 0–100:

np.percentile(tips["total_bill"], [10, 25, 50, 75, 90])
[10.34   13.3475 17.795  24.1275 32.235 ]

Pandas’ quantile is the same idea on a 0–1 scale instead of 0–100, and it hands back a labeled Series rather than a bare array — see the pandas Series.quantile documentation for the interpolation options it supports:

tips["total_bill"].quantile([0.1, 0.25, 0.5, 0.75, 0.9])
0.10    10.3400
0.25    13.3475
0.50    17.7950
0.75    24.1275
0.90    32.2350
Name: total_bill, dtype: float64

The 50th percentile is just the median under a different name — one more reminder that these three questions (center, spread, position) are really one connected picture, not three separate topics.

.describe(): A One-Stop Summary, Read Critically

Rather than computing each statistic by hand, .describe() bundles most of them into one table:

tips[["total_bill", "tip", "size"]].describe()
       total_bill         tip        size
count  244.000000  244.000000  244.000000
mean    19.785943    2.998279    2.569672
std      8.902412    1.383638    0.951100
min      3.070000    1.000000    1.000000
25%     13.347500    2.000000    2.000000
50%     17.795000    2.900000    2.000000
75%     24.127500    3.562500    3.000000
max     50.810000   10.000000    6.000000

Read it as a story, not a checklist. count tells you there are no missing values in these columns. mean sitting above 50% (the median) in both total_bill and tip is the skew signature again — you already know why it’s there. And size behaves like a discrete count more than a continuous measurement: its mean of 2.57 isn’t a party size anyone actually has, it’s just the arithmetic center of mostly 2s, 3s, and 4s. .describe() is a fast first look, not a substitute for the questions above.

Three Gotchas Worth Knowing

NumPy and pandas disagree on the default standard deviation formula. NumPy’s std() divides by n by default (the population formula, ddof=0); pandas’ .std() divides by n - 1 by default (the sample formula, ddof=1), because it assumes your data is a sample drawn from a larger population — the normal case for real datasets. Mixing the two without noticing gives you two “correct-looking” numbers that don’t match:

np.std(tips["total_bill"])          # numpy default: ddof=0
tips["total_bill"].std()            # pandas default: ddof=1
8.884150577771132
8.902411954856856

The gap is small here because n is 244, but on a small sample the two formulas can diverge meaningfully. If you need NumPy’s population formula to match pandas, pass ddof=1 explicitly; if you need pandas to match NumPy’s default, pass ddof=0.

The mean is not robust to outliers — the median barely notices them. Add a single implausible $500 bill to the dataset and watch what happens to each:

with_outlier = pd.concat([tips["total_bill"], pd.Series([500.0])], ignore_index=True)

print(f"mean without outlier:   {tips['total_bill'].mean():.2f}")
print(f"mean with outlier:      {with_outlier.mean():.2f}")
print(f"median without outlier: {tips['total_bill'].median():.2f}")
print(f"median with outlier:    {with_outlier.median():.2f}")
mean without outlier:   19.79
mean with outlier:      21.75
median without outlier: 17.80
median with outlier:    17.81

One extra row out of 245 dragged the mean up by almost $2. The median moved by a single cent. When a dataset might contain data-entry errors or genuine extreme values, lead with the median, or report both and let the gap speak for itself.

Calling .mean() or .std() on a whole mixed-type DataFrame can raise an error instead of silently skipping columns. tips has category columns (sex, smoker, day, time) alongside numeric ones, and recent pandas refuses to guess what you meant:

try:
    tips.mean()
except TypeError as e:
    print("TypeError:", e)

tips.mean(numeric_only=True)
TypeError: 'Categorical' with dtype category does not support operation 'mean'
total_bill    19.785943
tip            2.998279
size           2.569672
dtype: float64

Select the numeric columns first, or pass numeric_only=True, rather than assuming the aggregation will politely ignore text and category columns on its own.

Wrapping Up

Descriptive statistics always answers one of three questions:

  • Center (mean, median, mode) — what’s typical
  • Spread (var, std, range, IQR) — how much values vary around that center
  • Position (quantile, percentile) — where a specific value ranks

Compute center and spread together, always — a mean without a standard deviation (or a median without an IQR) is a number with no context for how much to trust it. And when mean and median disagree, believe the skew, not the mean.

If you want to build these ideas into a fuller statistics foundation — sampling, distributions, z-scores, and more on real datasets — the Measures of Center & Variability module in our free Statistics & Probability course picks up exactly where this post leaves off. And once your data is described honestly, Pandas GroupBy is the natural next step for asking these same center-and-spread questions per group instead of across the whole dataset.

More from the blog