Python Lesson 39 of 71

Seaborn: Statistical Plots & Charts That Actually Communicate

You have a DataFrame. You want to see it — is mrr driven by seats? Is latency worse in one region? Which plan tier actually pays? You could reach for matplotlib and hand-assemble every bar, tick and legend. Or you could describe the question to seaborn and let it do the statistics and the styling for you.

That is the whole pitch. Seaborn is not a replacement for matplotlib — it is a layer on top of it. Everything you learned about Figure, Axes, ax.set_title() and plt.savefig() in the matplotlib lesson still applies, because a seaborn plot is a matplotlib plot underneath. What seaborn adds is three things beginners spend weeks re-inventing by hand: statistical defaults (it computes means, confidence intervals, kernel densities and regressions for you), tidy-data thinking (you pass a whole DataFrame plus column names, not pre-sliced arrays), and good-looking output out of the box.

Everything below was run on Python 3.12.3 with seaborn 0.13.2, matplotlib 3.11.0, pandas 3.0.3, numpy 2.5.1. Every figure was rendered to a file before its description was written — where I say “the bars are ordered free, pro, enterprise,” it is because I looked at the PNG.


Why this matters

Here is the same chart two ways. You want the average monthly revenue per plan tier. In raw matplotlib you compute the groups yourself, then draw bars:

# matplotlib: you do the statistics, then the drawing
import matplotlib.pyplot as plt
means = df.groupby("plan")["mrr_usd"].mean()      # you aggregate
fig, ax = plt.subplots()
ax.bar(means.index, means.values)                  # you place bars
ax.set_ylabel("mean MRR (USD)")                     # you label everything

In seaborn you name the columns and the question, and it does the grouping, the aggregation, and a bootstrapped confidence interval:

# seaborn: name the columns, get the statistics for free
import seaborn as sns
sns.barplot(data=df, x="plan", y="mrr_usd")         # groups + means + 95% CI, done

Both make bars. Only one of them computed a confidence interval, and only one of them will still be one line when you add hue="region" to split every bar by region. That is the leverage seaborn gives you — and it is exactly why beginners get burned, because seaborn’s “helpful” defaults (a bar is a mean, not a sum; a barplot wants tidy data; a relplot ignores your ax=) are invisible until they bite.

The mental model, and hold onto it for the whole lesson: seaborn = matplotlib + statistical defaults + tidy-data thinking. Three ideas, and every confusing thing seaborn does traces back to one of them.

Concern matplotlib (the engine) seaborn (the statistical layer)
Input shape NumPy arrays / lists you pre-slice a whole tidy DataFrame + column names
Aggregation you compute means/CIs yourself computed for you (mean, CI, KDE, regression)
Splitting by a category a loop + manual colours + manual legend hue="col", one keyword
Small multiples (facets) you build the subplot grid by hand col="col", row="col", one keyword
Default look plain, needs styling publication-ready themes out of the box
Fine control (titles, limits, annotations) total, native drop to the underlying ax (still matplotlib)
Non-statistical / bespoke art its natural home leave seaborn, use matplotlib

Read that last row now so it lands later: seaborn is a statistical library. When your job is not statistical — a hand-tuned annotation, an unusual custom shape, a dashboard of dials — you drop back down to matplotlib, and seaborn makes that easy because you were always standing on matplotlib anyway.


The dataset we will use everywhere

sns.load_dataset("tips") and friends are lovely, but they download over the network — and on a locked-down machine or an offline box they fail hard (we will see that exact error in troubleshooting). So we build our own realistic, tidy dataset once and reuse it. It models SaaS signups: each row is one customer account.

# save as make_data.py — every example imports this
import pandas as pd
import numpy as np

def make_signups(n=300, seed=7):
    rng = np.random.default_rng(seed)
    region = rng.choice(["APAC", "EMEA", "AMER"], size=n, p=[0.30, 0.35, 0.35])
    plan   = rng.choice(["free", "pro", "enterprise"], size=n, p=[0.50, 0.35, 0.15])

    # seats are driven by the plan tier
    seat_base = np.select([plan == "free", plan == "pro", plan == "enterprise"],
                          [2, 8, 35])
    seats = (seat_base + rng.poisson(2, n)
             + (plan == "enterprise") * rng.integers(0, 25, n)).clip(1)

    # revenue = seats * price-per-seat(plan) + noise  -> mrr tracks seats tightly
    price = np.select([plan == "free", plan == "pro", plan == "enterprise"],
                      [0.0, 25.0, 90.0])
    mrr = (seats * price + rng.normal(0, 40, n)).clip(0)

    # latency depends on REGION only (distance from origin) -> ~independent of seats
    region_lat = np.select([region == "APAC", region == "EMEA", region == "AMER"],
                           [180, 90, 60])
    latency = (region_lat + rng.normal(0, 20, n)).clip(5)

    # satisfaction falls as latency rises -> a strong NEGATIVE correlation to find later
    nps = (75 - 0.25 * latency + rng.normal(0, 8, n)).clip(-100, 100)

    return pd.DataFrame({
        "region": region, "plan": plan,
        "seats": seats.astype(int),
        "mrr_usd": mrr.round(2),
        "latency_ms": latency.round(1),
        "nps": nps.round(1),
    })

df = make_signups()
print(df.head(8).to_string(index=False))
region plan  seats  mrr_usd  latency_ms  nps
  EMEA  pro     11   239.89       118.2 51.0
  AMER free      2     0.00        59.6 43.8
  AMER  pro      9   237.37       102.0 61.3
  APAC  pro     10   213.51       162.4 52.6
  EMEA  pro     12   305.02       103.9 56.9
  AMER free      4    16.46        40.0 73.1
  APAC free      4     0.00       216.3  9.6
  AMER free      4     5.36        53.4 60.7

Because we seeded the generator (seed=7), you will get exactly these numbers. Six columns: two categorical (region, plan), four numeric (seats, mrr_usd, latency_ms, nps). That mix — categories to split by, numbers to measure — is what every plot family below feeds on.

Setup. python3 -m venv .venv && source .venv/bin/activate (Windows: .venv\Scripts\activate), then pip install seaborn. That single install pulls in matplotlib, pandas and numpy as dependencies. For the correlation clustermap at the end you also need pip install scipy. If you are new to virtual environments, see pip & Virtual Environments; the DataFrame mechanics here lean on pandas Series & DataFrames and the vectorised np.select calls on NumPy arrays & broadcasting.


Tidy data: the one rule seaborn insists on

Before any plot, the rule that unlocks all of seaborn. Seaborn wants tidy (also called long-form) data:

In our frame, one row = one customer, and region, plan, mrr_usd are each their own column. That is why every seaborn call looks like sns.something(data=df, x="plan", y="mrr_usd", hue="region")x, y and hue are just column names. Seaborn reaches into the frame, groups by the categorical columns, and computes whatever statistic the plot needs. You never slice the data yourself.

Tidy (long) — seaborn loves this Messy (wide) — seaborn fights this
one row per observation one row per group, groups spread across columns
one column per variable one variable smeared across many columns
x=, y=, hue= are column names no single column to name
adding a category = adding rows adding a category = adding a column
df.melt(...) produces it df.pivot(...) produces it

The classic beginner shape is wide: a spreadsheet with a column per group. Say monthly signups per region, one column per region:

wide = pd.DataFrame({
    "month": ["Jan", "Feb", "Mar"],
    "APAC":  [120, 135, 150],
    "EMEA":  [ 90,  95, 110],
    "AMER":  [200, 210, 230],
})
print(wide)
  month  APAC  EMEA  AMER
0   Jan   120    90   200
1   Feb   135    95   210
2   Mar   150   110   230

There is no single column called “region” and no single column called “signups” to hand seaborn — the region is the column name and the signups are scattered across three columns. To plot signups-by-region-over-time you must first reshape wide → long with melt, which un-pivots the group columns into two tidy columns: one holding the old column names, one holding the values.

long = wide.melt(id_vars="month", var_name="region", value_name="signups")
print(long)
  month region  signups
0   Jan   APAC      120
1   Feb   APAC      135
2   Mar   APAC      150
3   Jan   EMEA       90
4   Feb   EMEA       95
5   Mar   EMEA      110
6   Jan   AMER      200
7   Feb   AMER      210
8   Mar   AMER      230

Nine rows now (3 months × 3 regions), three tidy columns, and sns.lineplot(data=long, x="month", y="signups", hue="region") just works. Learn melt and you have defused the single most common seaborn frustration.

melt parameter Meaning
id_vars column(s) to keep as-is (identifiers, e.g. "month")
value_vars which columns to un-pivot (default: everything not in id_vars)
var_name name for the new column holding the old column names
value_name name for the new column holding the values

If you already know pandas reshaping, melt (wide→long) is the inverse of pivot/pivot_table (long→wide); pandas groupby, merge & reshaping covers both directions in depth.


The plot-selection map

Before the families, here is the whole decision you make every time — from tidy data, through the question you are asking, to the plot family, and finally to the one fork that trips everyone: axes-level vs figure-level functions. Keep this map in your head; the rest of the lesson colours it in.

The path reads left → right: (1) get tidy/long data (melt if it arrived wide) → (2) ask what you want to show(3) that question names a plot family → (4) pick the axes-level function (draws on your ax, returns an Axes) or the figure-level function (builds its own figure, returns a grid). Badges 5 and 6 are where the afternoons get lost.

Decision path from tidy data through the question you are asking to the matching seaborn plot family and finally the axes-level versus figure-level fork, with six numbered badges marking the tidy-data requirement, the question-first rule, the relational and distribution families, the barplot-is-a-mean surprise, axes-level functions that respect ax, and figure-level functions that ignore ax and return a grid

Your question Family Go-to functions
How do two numbers relate? Relational scatterplot, lineplot, relplot
What does one number’s distribution look like? Distribution histplot, kdeplot, ecdfplot, rugplot, displot
How does a number compare across categories? Categorical boxplot, violinplot, barplot, countplot, stripplot/swarmplot, pointplot, catplot
Is there a linear trend / model fit? Regression regplot, lmplot
How do many variables move together? Matrix heatmap, clustermap

The five plot families

Every example uses the df from make_signups(). Because seaborn draws onto matplotlib, we import both and always call plt.show() (or savefig) at the end.

Relational — how do two numbers relate?

scatterplot for points, lineplot for a trend (with a confidence band when there are repeated x-values). Both are axes-level (remember that word — it returns in a big way soon).

import matplotlib.pyplot as plt
import seaborn as sns

fig, ax = plt.subplots(figsize=(6, 4))
sns.scatterplot(data=df, x="seats", y="mrr_usd",
                hue="plan",          # colour by tier
                size="latency_ms",   # marker size by latency
                sizes=(20, 200), alpha=0.7, ax=ax)
ax.set_title("Revenue vs seats, coloured by plan")
plt.show()

The figure I rendered: a tight, almost-straight cloud rising left→right — mrr_usd climbs with seats because revenue is priced per seat. Three colours separate the tiers cleanly: free clustered at the bottom-left near zero revenue, pro in the middle, enterprise sprawling up to the top-right with large markers. One scatterplot call encoded four variables at once: x, y, colour (hue) and size.

For a trend line with a statistical band, lineplot shines when x repeats. Here are daily active users measured across four independent runs:

import numpy as np
rng = np.random.default_rng(1)
daily = pd.DataFrame({
    "day": np.tile(np.arange(1, 31), 4),
    "run": np.repeat([1, 2, 3, 4], 30),
    "active": (np.tile(np.arange(1, 31), 4) * 3 + rng.normal(0, 8, 120) + 50).round(),
})

fig, ax = plt.subplots(figsize=(6, 4))
sns.lineplot(data=daily, x="day", y="active", ax=ax)   # averages the 4 runs per day
ax.set_title("Active users/day (mean of 4 runs, 95% CI band)")
plt.show()

The figure: a single line rising from ~50 to ~140 with a translucent band hugging it. Seaborn saw four active values per day, plotted their mean, and bootstrapped a 95% confidence interval for the shaded band — statistics you would otherwise compute by hand. No hue here, so it collapsed all runs into one aggregated line.

Relational function Draws Aggregates repeated x? Level
scatterplot points no axes
lineplot connected line yes → mean + CI band axes
relplot scatter or line (kind=) across facets yes figure

Distribution — what does one column look like?

Four functions answer “what is the shape of this number?”: histplot (bars), kdeplot (smooth density), ecdfplot (cumulative), rugplot (ticks at each value). All axes-level.

fig, ax = plt.subplots(figsize=(6, 4))
sns.histplot(data=df, x="latency_ms", hue="region",
             kde=True, ax=ax)              # bars + a smooth density overlay
ax.set_title("Latency distribution by region")
plt.show()

The figure: three overlapping coloured histograms with smooth KDE curves laid over them. They sit in three distinct humps — AMER lowest (~60 ms), EMEA middle (~90 ms), APAC highest (~180 ms) — exactly as we built the data (latency is driven by region). The hue="region" split three sub-populations without a single loop.

# the cumulative view answers "what fraction is under X ms?"
fig, ax = plt.subplots(figsize=(6, 4))
sns.ecdfplot(data=df, x="latency_ms", hue="region", ax=ax)
ax.axhline(0.5, ls="--", color="grey")     # the median line
ax.set_title("ECDF: fraction of accounts under a latency")
plt.show()

The figure: three S-curves climbing 0→1 left to right. Read across from 0.5 to find each region’s median latency; AMER’s curve reaches 0.5 far to the left (~59 ms), APAC’s far to the right (~178 ms). ECDFs never bin, so they never lie about shape the way a badly-binned histogram can.

A word of caution on kdeplot (and the KDE overlay in histplot): the smooth curve is controlled by a bandwidth parameter (bw_adjust), and the default can either over-smooth a genuinely bimodal distribution into a single hump or, with too small a bandwidth, invent wiggles that are just noise. A KDE is a model of your data, not the data — when the shape matters for a decision, cross-check it against a plain histplot and, for tail questions, an ecdfplot. Three views of the same column rarely all lie the same way.

Distribution function Shows Good for
histplot binned counts (bars) overall shape, hue overlays, stat="density"
kdeplot smoothed density curve comparing shapes; beware over-smoothing
ecdfplot cumulative fraction medians, percentiles, no binning bias
rugplot a tick per raw value overlay on the above to show actual data points
displot any of the above across facets figure-level distribution EDA

Categorical — a number across categories

This family compares a numeric column across the levels of a categorical one — and it contains the single most surprising default in seaborn.

fig, ax = plt.subplots(figsize=(6.5, 4))
sns.boxplot(data=df, x="plan", y="mrr_usd", hue="region", ax=ax)
ax.set_title("Revenue spread by plan, split by region")
plt.show()

The figure: three groups on the x-axis (free, pro, enterprise), each split into three coloured boxes (one per region). free boxes hug zero, pro sits mid-range, enterprise boxes are tall and high with long whiskers and a few outlier dots — its revenue varies wildly because enterprise seat counts do. A boxplot packs median, quartiles and outliers into each box.

Now the trap. A barplot does not sum your data — it shows the mean of each group plus a bootstrapped CI. Watch:

fig, ax = plt.subplots(figsize=(6, 4))
sns.barplot(data=df, x="plan", y="mrr_usd", errorbar=("ci", 95), ax=ax)
ax.set_title("barplot shows the MEAN per plan (not the total)")
plt.show()

# prove what the bars actually are:
print(df.groupby("plan")["mrr_usd"].mean().round(1).to_string())
print("---")
print(df.groupby("plan")["mrr_usd"].sum().round(1).to_string())
plan
enterprise    4300.4
free            14.2
pro            247.3
---
plan
enterprise    163414.5
free            2103.9
pro            28189.3

The bar heights match the first block (the means), not the second (the sums). The enterprise bar is drawn at 4300.4, its average revenue — nowhere near the 163414.5 total. Beginners reach for barplot expecting a sum-of-revenue chart and quietly ship a chart of averages. If you want totals, say so:

sns.barplot(data=df, x="plan", y="mrr_usd", estimator="sum", errorbar=None)
# now the bars are 2103.9 / 28189.3 / 163414.5

countplot is the “just count the rows” cousin — no y, it tallies occurrences:

fig, ax = plt.subplots(figsize=(6, 4))
sns.countplot(data=df, x="region", hue="plan", ax=ax)
ax.set_title("Number of accounts per region and plan")
plt.show()
# region totals: EMEA 107, AMER 106, APAC 87

The figure: three region groups, each split into free/pro/enterprise counts. free bars dominate every region (we generated 50% free); enterprise bars are shortest. EMEA and AMER are taller overall (107 and 106 accounts) than APAC (87).

To show every raw point rather than a summary, use stripplot (jittered points) or swarmplot (non-overlapping points). pointplot connects group means with CI whiskers — good for interaction effects.

Categorical function Shows per category Note
boxplot median, quartiles, outliers compact five-number summary
violinplot mirrored KDE (distribution shape) needs enough points to be honest
barplot mean + CI (⚠️ not a sum!) estimator="sum" for totals
countplot count of rows (no y) it’s histplot for a category
stripplot every point, jittered overlaps with lots of data
swarmplot every point, non-overlapping warns + drops points if too dense
pointplot mean as a dot + CI, connected reads interactions across categories
catplot any of the above across facets figure-level entry point

Two nuances worth internalising. A violinplot is a boxplot whose sides are a mirrored KDE, so it shows the shape of each group’s distribution (bimodal? skewed?) — but a KDE invents smoothness, so on a category with only a handful of points a violin will draw a confident-looking curve out of almost nothing. Use violins when each group has enough data (roughly dozens of points) to be honest; below that, prefer a boxplot or a stripplot that shows the actual points. And pointplot is the tool for interaction effects: it plots each group’s mean as a dot and connects the dots across a second categorical (hue), so non-parallel connecting lines signal an interaction — the effect of one category depends on the level of another. That “are these lines parallel?” read is something a stack of bars makes almost impossible to see.

Regression — is there a linear trend?

regplot fits and draws a regression line with a CI band; lmplot is its figure-level sibling that can facet.

fig, ax = plt.subplots(figsize=(6, 4))
sns.regplot(data=df, x="seats", y="mrr_usd", ax=ax,
            scatter_kws={"alpha": 0.4})
ax.set_title("Linear fit: revenue ~ seats")
plt.show()

The figure: the seats-vs-revenue cloud with a straight fit line slicing up through it and a thin CI band around the line. The fit is tight because we priced revenue per seat — the points barely stray from the line. regplot did the ordinary-least-squares fit for you.

# lmplot = regplot + faceting; note it returns a grid, not an Axes (see next section)
g = sns.lmplot(data=df, x="seats", y="mrr_usd", hue="plan", height=4)

The figure: one scatter with three separate fit lines, one per plan tier, each its own colour — enterprise steepest (priciest seats), free flat along zero.

Matrix — how do many variables move together?

heatmap renders a 2-D matrix as coloured cells; the classic use is a correlation matrix. Compute correlations with pandas first, then draw:

corr = df[["seats", "mrr_usd", "latency_ms", "nps"]].corr()
print(corr.round(2).to_string())
            seats  mrr_usd  latency_ms   nps
seats        1.00     0.99        0.02  0.01
mrr_usd      0.99     1.00        0.02  0.01
latency_ms   0.02     0.02        1.00 -0.85
nps          0.01     0.01       -0.85  1.00
fig, ax = plt.subplots(figsize=(5.5, 4.5))
sns.heatmap(corr, annot=True, cmap="vlag",
            vmin=-1, vmax=1, center=0,       # diverging scale, white at 0
            square=True, linewidths=0.5, ax=ax)
ax.set_title("Correlation matrix")
plt.show()

The figure: a 4×4 grid of coloured cells with the numbers printed in each (annot=True). The seatsmrr_usd cells glow deep red (0.99 — revenue tracks seats), the latency_msnps cells glow deep blue (−0.85 — satisfaction falls as latency rises), and the cross cells (seatslatency) are near-white (0.02 — unrelated). Because I used a diverging palette (vlag) centered at 0, positive and negative correlations get opposite hues and zero is white — the only honest way to colour a signed quantity.

clustermap goes further: it reorders rows/columns by hierarchical clustering so related variables sit together (it needs scipy). It is figure-level and returns a ClusterGrid.

Matrix function Does Level
heatmap colour a matrix; annot=True prints values axes
clustermap heatmap + dendrograms, clustered order (needs scipy) figure

Figure-level vs axes-level: the trap that eats an afternoon

You have seen “axes-level” and “figure-level” flagged all through the families. This is the seaborn concept that separates people who get the library from people who fight it forever. Get it wrong and your carefully-built subplot appears blank while a second, unwanted figure shows up somewhere else.

Axes-level functions (scatterplot, lineplot, histplot, kdeplot, boxplot, violinplot, barplot, countplot, heatmap, regplot, …) draw onto one Axes. Pass ax= and they draw there; omit it and they use the current axes. They return that Axes. They are the ones you slot into a matplotlib subplot grid.

Figure-level functions (relplot, displot, catplot, lmplot, jointplot, pairplot) each own an entire figure. They create it, arrange one or more subplots (facets) inside it, and return a grid object — a FacetGrid, JointGrid, or PairGridnot an Axes. Crucially, they do not accept your ax=.

Here is the trap in full. You build a 1×2 subplot grid, then try to put a relplot in the left panel:

fig, (ax_left, ax_right) = plt.subplots(1, 2, figsize=(11, 4))
sns.relplot(data=df, x="seats", y="mrr_usd", hue="plan", ax=ax_left)  # ⚠️ wrong
UserWarning: relplot is a figure-level function and does not accept the `ax`
parameter. You may wish to try scatterplot

Your left panel stays empty, relplot builds its own separate figure off to the side, and you get that warning — which beginners scroll past. The other figure-level functions warn in their own words, and lmplot doesn’t even accept the keyword:

displot :  `displot` is a figure-level function and does not accept the ax= parameter. You may wish to try histplot.
catplot :  catplot is a figure-level function and does not accept target axes. You may wish to try boxplot
lmplot  :  TypeError: lmplot() got an unexpected keyword argument 'ax'

The fix has two shapes. If you need the plot inside a subplot you built, use the axes-level twin (the warning even names it — relplotscatterplot):

# FIX 1: axes-level function respects your ax
fig, (ax_left, ax_right) = plt.subplots(1, 2, figsize=(11, 4))
sns.scatterplot(data=df, x="seats", y="mrr_usd", hue="plan", ax=ax_left)   # ✅ draws left
sns.boxplot(data=df, x="plan", y="mrr_usd", ax=ax_right)                    # ✅ draws right

If you want faceting (multiple panels driven by a column), let the figure-level function own the figure and drive the grid it returns — don’t fight it:

# FIX 2: use the figure-level function and steer the returned grid
g = sns.relplot(data=df, x="seats", y="mrr_usd", hue="plan", col="region")
g.set_axis_labels("Seats", "Monthly revenue (USD)")
g.set_titles("{col_name}")
g.figure.suptitle("Revenue vs seats, faceted by region", y=1.03)
g.savefig("faceted.png")

Notice g.figure — the grid exposes the underlying matplotlib Figure, so you can still reach matplotlib; you just go through the grid. Here is the crisp rule and the return-type table to memorise.

Axes-level Figure-level
Examples scatterplot, lineplot, histplot, kdeplot, boxplot, barplot, heatmap, regplot relplot, displot, catplot, lmplot, jointplot, pairplot
Draws on one Axes (yours via ax=) its own new Figure
Accepts ax=? yes no (warns or TypeError)
Returns an Axes a FacetGrid / JointGrid / PairGrid
Faceting (col, row)? no yes
Sizing figsize on your subplots height + aspect on the call
Use when placing into a custom subplot layout one-line faceted EDA
Function Kind Returns
scatterplot, lineplot axes Axes
histplot, kdeplot, ecdfplot axes Axes
boxplot, violinplot, barplot, countplot axes Axes
heatmap, regplot axes Axes
relplot figure FacetGrid
displot figure FacetGrid
catplot figure FacetGrid
lmplot figure FacetGrid
jointplot figure JointGrid
pairplot figure PairGrid
clustermap figure ClusterGrid

The mnemonic: plural-ish “…plot” names that take col/row own the figure; the plain <thing>plot names take your ax. relplot/displot/catplot are the figure-level dispatchers — each wraps a whole family via kind= (relplot(kind="line"), displot(kind="kde"), catplot(kind="box")).


Faceting: small multiples for EDA

Faceting means one small chart per subgroup, laid on a grid — the fastest way to explore a dataset. Figure-level functions do it with two keywords: col= (a column of panels) and row= (a row of panels). hue= (colour within a panel) works on axes-level functions too.

# one boxplot panel per region, in a row
g = sns.catplot(data=df, x="plan", y="latency_ms", col="region",
                kind="box", height=3.5, aspect=0.8)
g.set_titles("{col_name}")

The figure: three side-by-side panels (APAC, EMEA, AMER). Every panel has the same three plan boxes, but the whole APAC panel sits high on the y-axis (latency ~180 ms), EMEA mid (~90 ms), AMER low (~60 ms) — because latency is regional, not plan-driven. Faceting made that pattern jump out instantly.

Split mechanism Keyword Where it appears Works on
Colour hue within one panel axes-level and figure-level
Columns of panels col side by side figure-level only
Rows of panels row stacked figure-level only
Wrap long facet rows col_wrap grid wraps to N columns figure-level only

For a scan of every pairwise relationship at once, pairplot builds a matrix of scatterplots (with distributions on the diagonal) — the classic first look at a new dataset:

g = sns.pairplot(df, hue="plan",
                 vars=["seats", "mrr_usd", "latency_ms", "nps"])

The figure: a 4×4 grid. Diagonal = each variable’s distribution (KDE per plan colour). Off-diagonal = scatter of every pair. The seatsmrr_usd cells show a crisp line (correlated); the latency_msnps cells show a clear downward cloud (anti-correlated); seatslatency cells look like formless blobs (independent). One line reproduced the entire correlation story visually.

jointplot zooms into one pair with marginal distributions on the sides:

g = sns.jointplot(data=df, x="latency_ms", y="nps", hue="plan", kind="scatter")

The figure: a central scatter sloping down (high latency → low NPS) with a histogram on the top edge (latency) and on the right edge (NPS). kind= swaps the centre: "reg" adds a fit line, "hex" bins into hexagons for dense data, "kde" draws contours.

Figure-level EDA tool Best for Centre / diagonal
pairplot all pairwise relationships in one grid scatter off-diagonal, KDE on diagonal
jointplot one pair + its marginal distributions scatter/reg/hex/kde + marginal histograms
FacetGrid fully custom faceting of any plot you .map() a function onto it

When the built-in figure-level functions do not cover your layout, FacetGrid is the manual engine underneath them:

g = sns.FacetGrid(df, col="region", hue="plan", height=3)
g.map_dataframe(sns.scatterplot, x="seats", y="mrr_usd")
g.add_legend()

That gives one scatter panel per region, coloured by plan — the same thing relplot(..., col="region") does, but assembled by hand when you need a plot type the dispatchers don’t expose.


Palettes, colour, and honesty

Colour in a statistical chart is not decoration — it is data. Pick the wrong palette type and you make a real analytical error. There are three kinds, and matching them to your data is non-negotiable.

Palette type Use for Examples Getting it wrong
Qualitative unordered categories (plan, region) deep, colorblind, Set2, tab10, husl implying an order that isn’t there
Sequential ordered / one-directional magnitude (counts, 0→max) viridis, Blues, rocket, mako a rainbow that fakes structure
Diverging signed values with a meaningful midpoint (correlations, ±%, profit/loss) vlag, coolwarm, RdBu, Spectral a sequential map hiding the sign of the data

The correlation heatmap above must use a diverging palette (vlag) centered at 0, because a correlation of −0.85 and +0.85 are opposites, not “a bit less” and “a bit more.” A sequential palette there would paint −0.85 and 0.02 similar light shades and bury the strongest signal in the whole matrix.

Set a palette globally with set_palette, or per-plot with palette=:

print(sns.color_palette("colorblind").as_hex())
['#0173b2', '#de8f05', '#029e73', '#d55e00', '#cc78bc',
 '#ca9161', '#fbafe4', '#949494', '#ece133', '#56b4e9']

Seaborn ships a colorblind palette for exactly this reason: roughly 1 in 12 men cannot distinguish the default red/green reliably. For anything you will share, sns.set_theme(palette="colorblind") is a one-line accessibility win. The default palette (deep) is ['#4c72b0', '#dd8452', '#55a868', ...] — pleasant, but colorblind is the safer default for published work.

When hue actively misleads. Colouring by a category with too many levels (say 15 regions) yields 15 near-identical hues nobody can tell apart — a legend that answers no question. Above ~8–10 categories, stop using hue; facet with col instead, or aggregate the long tail into an “other” bucket. And never encode an ordered quantity (like a rating 1–5) with a qualitative palette — the reader can’t tell 5 is “more” than 1 if the colours have no order.


Themes and context

set_theme() is the master switch — call it once at the top of a script and every subsequent plot (seaborn and raw matplotlib, because it edits matplotlib’s rcParams) inherits it. It bundles a style (the background/grid look) and a context (the scale of fonts and lines for the medium).

sns.set_theme(style="whitegrid", context="talk", palette="colorblind")
style= Look
darkgrid grey background, white grid (default)
whitegrid white background, grey grid
dark grey background, no grid
white clean white, no grid
ticks white + axis tick marks (publication)
context= Scales fonts/lines for Relative size
paper dense figures, print smallest
notebook interactive analysis (default) medium
talk slides, presentations large
poster posters, big screens largest

set_style and set_context set those axes independently; sns.despine() removes the top/right spines for a cleaner look; sns.set_theme() with no args resets everything to defaults — handy after a script has been mutating global state.

⚠️ Because set_theme writes matplotlib’s global rcParams, import seaborn followed by sns.set_theme() changes the look of your plain matplotlib plots too. That surprises people who mix the two — it is a feature (consistent styling), but if you need matplotlib’s original look back, call matplotlib.pyplot.style.use("default") or scope with with sns.axes_style("white"):.


Dropping to matplotlib (and when to leave seaborn entirely)

Because a seaborn plot is a matplotlib plot, the escape hatch is always open. Axes-level functions return the Axes; figure-level functions expose g.figure and g.axes. Anything matplotlib can do — annotations, log scales, custom ticks, twin axes — you do through those handles:

fig, ax = plt.subplots(figsize=(6, 4))
sns.scatterplot(data=df, x="seats", y="mrr_usd", hue="plan", ax=ax)
# now it's pure matplotlib:
ax.set_yscale("log")
ax.set_ylabel("Monthly revenue (USD, log scale)")
ax.axhline(1000, ls="--", color="grey")
ax.annotate("enterprise threshold", xy=(30, 1000), xytext=(35, 3000),
            arrowprops={"arrowstyle": "->"})
sns.move_legend(ax, "upper left", bbox_to_anchor=(1, 1), title="Plan")
fig.tight_layout()

sns.move_legend() is the clean way to reposition (or move outside the axes) a legend seaborn drew — better than matplotlib’s ax.legend() which would rebuild it and lose seaborn’s grouping.

And the honest part: seaborn is a statistical-graphics library, and outside that lane you should leave it.

Situation Reach for
Statistical EDA, distributions, group comparisons, faceting seaborn
Full manual control of every artist, bespoke non-statistical figures matplotlib (seaborn’s own engine)
Interactive charts (hover, zoom, pan) for the web / dashboards plotly, bokeh, or altair
Millions of points that must stay interactive datashader / plotly with WebGL
Publication maths/vector art unrelated to data matplotlib or a vector tool

If you find yourself passing a dozen **kwargs to force a seaborn plot into a shape it resists, that is the signal to build it in matplotlib directly. Seaborn’s job is to make the common statistical plot trivial — not every plot.


Hands-on lab

Build one script that exercises every family, then reproduces and fixes the figure-level trap. Create a venv, pip install seaborn scipy, save make_data.py from earlier, and work through the steps. We render to files with the non-interactive Agg backend so it runs anywhere (no display needed) — the same way every figure in this lesson was verified.

# lab.py
import matplotlib
matplotlib.use("Agg")                 # headless: render to files, no window
import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd
from make_data import make_signups

sns.set_theme(style="whitegrid", palette="colorblind")
df = make_signups()

Step 1 — a distribution. How is latency distributed, split by region?

fig, ax = plt.subplots(figsize=(6, 4))
sns.histplot(data=df, x="latency_ms", hue="region", kde=True, ax=ax)
ax.set_title("Latency by region")
fig.savefig("01_dist.png", dpi=110, bbox_inches="tight")
plt.close(fig)

You should see 01_dist.png with three separated humps (AMER low, EMEA mid, APAC high). What just happened: one axes-level call binned three sub-populations and overlaid smooth densities.

Step 2 — a categorical boxplot with hue. Revenue spread per plan, split by region.

fig, ax = plt.subplots(figsize=(6.5, 4))
sns.boxplot(data=df, x="plan", y="mrr_usd", hue="region", ax=ax)
ax.set_title("Revenue by plan and region")
ax.tick_params(axis="x", rotation=0)
fig.savefig("02_box.png", dpi=110, bbox_inches="tight")
plt.close(fig)

You should see nine boxes (3 plans × 3 regions); enterprise tall and high. What just happened: hue turned one categorical axis into a grouped comparison.

Step 3 — a scatter with hue + size. Four variables in one plot.

fig, ax = plt.subplots(figsize=(6, 4))
sns.scatterplot(data=df, x="seats", y="mrr_usd", hue="plan",
                size="latency_ms", sizes=(20, 200), alpha=0.7, ax=ax)
ax.set_title("Revenue vs seats (colour=plan, size=latency)")
fig.savefig("03_scatter.png", dpi=110, bbox_inches="tight")
plt.close(fig)

You should see a rising cloud, tiered by colour, marker size varying. What just happened: seaborn mapped two extra columns onto colour and size automatically.

Step 4 — a correlation heatmap. Which numbers move together?

corr = df[["seats", "mrr_usd", "latency_ms", "nps"]].corr()
fig, ax = plt.subplots(figsize=(5.5, 4.5))
sns.heatmap(corr, annot=True, cmap="vlag", vmin=-1, vmax=1,
            center=0, square=True, linewidths=0.5, ax=ax)
ax.set_title("Correlations")
fig.savefig("04_heat.png", dpi=110, bbox_inches="tight")
plt.close(fig)

You should see deep-red seats↔mrr (0.99), deep-blue latency↔nps (−0.85), near-white elsewhere. What just happened: a diverging palette centered at 0 made the sign of every correlation legible.

Step 5 — a figure-level faceted plot. One scatter panel per region, in one line.

g = sns.relplot(data=df, x="seats", y="mrr_usd", hue="plan",
                col="region", height=3.5, aspect=0.9)
g.set_titles("{col_name}")
g.figure.suptitle("Revenue vs seats, faceted by region", y=1.03)
g.savefig("05_facet.png", dpi=110, bbox_inches="tight")

You should see three panels sharing axes, coloured by plan. What just happened: relplot (figure-level) built its own multi-panel figure — note we saved with g.savefig, not fig.savefig, because the grid owns the figure.

Step 6 — reproduce the trap, then fix it. Try to force a figure-level function into a subplot:

# THE TRAP: ax= is ignored, left panel stays empty, a warning prints
fig, (axL, axR) = plt.subplots(1, 2, figsize=(11, 4))
sns.relplot(data=df, x="seats", y="mrr_usd", ax=axL)     # ⚠️ ignored
fig.savefig("06_broken.png", dpi=110, bbox_inches="tight")
plt.close("all")
UserWarning: relplot is a figure-level function and does not accept the `ax`
parameter. You may wish to try scatterplot
# THE FIX: use the axes-level twin, which respects ax=
fig, (axL, axR) = plt.subplots(1, 2, figsize=(11, 4))
sns.scatterplot(data=df, x="seats", y="mrr_usd", hue="plan", ax=axL)
sns.boxplot(data=df, x="plan", y="latency_ms", ax=axR)
fig.suptitle("Fixed: axes-level functions honour ax=")
fig.savefig("06_fixed.png", dpi=110, bbox_inches="tight")
plt.close("all")

You should see 06_broken.png with an empty left panel (the trap) and 06_fixed.png with both panels drawn (the fix). What just happened: you felt the single most common seaborn mistake and corrected it with the axes-level twin.

Step 7 — reshape wide → long, then plot. Prove melt unlocks a chart that was impossible on wide data.

wide = pd.DataFrame({"month": ["Jan", "Feb", "Mar"],
                     "APAC": [120, 135, 150], "EMEA": [90, 95, 110],
                     "AMER": [200, 210, 230]})
long = wide.melt(id_vars="month", var_name="region", value_name="signups")

fig, ax = plt.subplots(figsize=(6, 4))
sns.lineplot(data=long, x="month", y="signups", hue="region", marker="o", ax=ax)
ax.set_title("Signups per region over time")
fig.savefig("07_melt.png", dpi=110, bbox_inches="tight")
plt.close(fig)

You should see three rising lines (AMER highest). What just happened: melt turned three group-columns into tidy region/signups columns so hue="region" finally had a column to name.

Run it: python lab.py. Seven PNGs land in your folder, and step 6 prints the warning to the console — the whole lesson, exercised end to end.


Common mistakes and troubleshooting

Symptom / message Cause Fix
UserWarning: relplot is a figure-level function and does not accept the \ax` parameter` (and your panel is blank) passed ax= to relplot/displot/catplot use the axes-level twin (scatterplot/histplot/boxplot) for a subplot, or drive the returned grid
TypeError: lmplot() got an unexpected keyword argument 'ax' lmplot/jointplot/pairplot reject ax= outright use regplot (axes-level) for a subplot; use lmplot only when you want its own figure
barplot bars look far too small / “my totals are wrong” barplot shows the mean, not the sum pass estimator="sum" for totals; keep default for averages
ValueError: Could not interpret value \revenue` for `y`. An entry with this name does not appear in `data`` column name typo or wrong frame check df.columns; the string must be an exact column name
plot expects x/y but you have a wide frame (a column per group) data is wide, not tidy df.melt(id_vars=..., var_name=..., value_name=...) first
UserWarning: 25.0% of the points cannot be placed; you may want to decrease the size of the markers or use stripplot swarmplot on too many/too dense points use stripplot (jitter), reduce size=, or switch to boxplot/violinplot
x-axis category labels overlap into an unreadable smear long category names on a narrow axis ax.tick_params(axis="x", rotation=45); on a grid g.set_xticklabels(rotation=45)
a hue legend has 15 indistinguishable colours too many hue categories facet with col= instead, or bucket the long tail into “other”
you called boxplot expecting an Axes but got a FacetGrid you actually called catplot/relplot/displot that dispatcher is figure-level; use the plain function name for an Axes
your palette=/hue= had no visible effect passed a wrong palette name, or hue column is constant check the palette name (sns.color_palette("name")), verify df[hue].nunique() > 1
your matplotlib plots suddenly changed style after importing seaborn sns.set_theme() edits global rcParams intended; reset with plt.style.use("default") or scope via with sns.axes_style(...)
URLError: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED]> from sns.load_dataset("tips") load_dataset downloads over the network build your own DataFrame (as we do), or fix certificates / go online
UserWarning: \distplot` is a deprecated function and will be removed in seaborn v0.14.0(anAttributeError` once it’s gone) distplot was deprecated (still callable in 0.13.2, removed in 0.14) use displot (figure-level) or histplot (axes-level)
harmless MatplotlibDeprecationWarning: vert: bool was deprecated on a boxplot seaborn 0.13.2 internals on matplotlib 3.11 ignore — it is library-internal, not your code; fixed in newer seaborn

Three gotchas deserve prose, because they are the ones that cost hours.

1. ax= silently ignored (the big one). The failure is nasty because it half-works: the figure-level function still produces a chart, just not where you told it to, and the warning scrolls off-screen in a notebook. The tell is a blank panel plus a stray extra figure. Burn the rule in: if you passed ax=, you must be calling an axes-level function (scatterplot, not relplot). If you want facets, don’t build subplots yourself at all — let relplot/catplot/displot own the figure and steer the grid it returns (g.set_titles(...), g.figure.suptitle(...)).

2. barplot is a mean, not a sum. This one ships wrong numbers to stakeholders, which is worse than an error — an error you’d notice. If your bar chart is meant to show totals (revenue, counts of events) and you used barplot with default settings, every bar is an average and the story is wrong. Say estimator="sum", or use countplot for row counts, or aggregate in pandas first and plot the result. When in doubt, print df.groupby(x)[y].mean() and check the bar heights match.

3. Wide vs long data. Ninety percent of “seaborn won’t plot my data” is a wide frame. Seaborn’s grammar is column names — if the thing you want on the x-axis (region, month, metric) is spread across column headers rather than living inside a column, seaborn has nothing to name. melt is the fix, every time. Internalise the tidy shape (one row per observation, one column per variable) and most seaborn confusion evaporates.


Cheat-sheet

Task Call
Import + set a good default theme import seaborn as sns; sns.set_theme(style="whitegrid", palette="colorblind")
Scatter of two numbers, split by category sns.scatterplot(data=df, x="a", y="b", hue="c", ax=ax)
Line + 95% CI band over repeated x sns.lineplot(data=df, x="t", y="v", ax=ax)
Histogram + density, split by category sns.histplot(data=df, x="v", hue="c", kde=True, ax=ax)
Smooth density only sns.kdeplot(data=df, x="v", hue="c", fill=True, ax=ax)
Cumulative distribution (medians/percentiles) sns.ecdfplot(data=df, x="v", ax=ax)
Box / violin per category sns.boxplot(...) / sns.violinplot(data=df, x="c", y="v", ax=ax)
Mean + CI per category sns.barplot(data=df, x="c", y="v", ax=ax)
Sum per category (not mean!) sns.barplot(..., estimator="sum", errorbar=None)
Count rows per category sns.countplot(data=df, x="c", ax=ax)
Every raw point per category sns.stripplot(...) / sns.swarmplot(data=df, x="c", y="v", ax=ax)
Linear fit + CI band sns.regplot(data=df, x="a", y="b", ax=ax)
Correlation heatmap (diverging, centered) sns.heatmap(df.corr(numeric_only=True), annot=True, cmap="vlag", center=0, ax=ax)
Faceted scatter (own figure) sns.relplot(data=df, x="a", y="b", col="c", hue="d")
Faceted categorical (own figure) sns.catplot(data=df, x="c", y="v", col="d", kind="box")
Faceted distribution (own figure) sns.displot(data=df, x="v", col="c", kind="hist")
All pairwise relationships sns.pairplot(df, hue="c")
One pair + marginals sns.jointplot(data=df, x="a", y="b", kind="reg")
Reshape wide → long df.melt(id_vars="id", var_name="k", value_name="v")
Move a legend outside the axes sns.move_legend(ax, "upper left", bbox_to_anchor=(1, 1))
Style a returned grid g.set_titles("{col_name}"); g.figure.suptitle("…")
Remove top/right spines sns.despine(ax=ax)
Reset all global styling sns.set_theme() (no args)

Which plot answers which question:

Question Plot
Do two numbers move together? scatterplot (+ regplot for the trend)
Is there a time/ordered trend with uncertainty? lineplot (CI band)
What is the shape / spread of one number? histplot / kdeplot / ecdfplot
How does a number differ across groups? boxplot / violinplot
What is the average per group (with CI)? barplot (mean) / pointplot
How many observations per group? countplot
Which variables correlate? heatmap of .corr()
Give me a first look at the whole dataset pairplot

Interview and exam questions

Q: In one sentence, what does seaborn add on top of matplotlib? A: Statistical defaults (means, confidence intervals, KDEs, regressions computed for you), tidy-DataFrame input (you pass column names, not pre-sliced arrays), and attractive themes — while remaining a matplotlib layer you can always drop back into.

Q: What is “tidy” (long-form) data, and why does seaborn want it? A: One row per observation and one column per variable. Seaborn’s API is built on naming columns (x="col", hue="col"), so each variable must live in its own column; then splitting, faceting and aggregating are all just “group by this column.”

Q: You have monthly sales with one column per region. sns.lineplot(data=df, x="month", y="signups", hue="region") fails — why, and what’s the fix? A: The data is wide — there’s no single signups column and no region column; regions are column headers. Reshape with df.melt(id_vars="month", var_name="region", value_name="signups") to get tidy columns, then the call works.

Q: What’s the difference between an axes-level and a figure-level function? Give two examples of each. A: Axes-level (scatterplot, boxplot) draw onto one Axes you can pass via ax= and return that Axes. Figure-level (relplot, catplot) create their own figure, support faceting via col/row, ignore ax=, and return a grid object (FacetGrid/JointGrid/PairGrid).

Q: You pass ax=my_ax to sns.displot(...) and nothing appears in my_ax. What happened? A: displot is figure-level; it does not accept ax=. It warned, ignored the argument, and built its own separate figure. Fix: use histplot/kdeplot (axes-level) to draw into my_ax, or accept displot’s own figure and drive the returned FacetGrid.

Q: A colleague’s sns.barplot(x="plan", y="revenue") shows tiny bars and they insist the totals are huge. Explain. A: barplot plots the mean per group plus a bootstrapped CI, not the sum. The bars are average revenue per account. For totals, pass estimator="sum" (and usually errorbar=None), or aggregate in pandas and plot that.

Q: When should you use a diverging palette versus a sequential one? A: Diverging (e.g. vlag, coolwarm) for signed values with a meaningful midpoint — correlations, percentage change, profit/loss — centered at zero so opposite signs get opposite hues. Sequential (e.g. viridis, Blues) for one-directional magnitude (counts, 0→max). Using sequential on signed data hides the sign.

Q: Why is colorblind a better default palette than deep for shared work? A: About 1 in 12 men have red-green colour vision deficiency; the colorblind palette is engineered to stay distinguishable for them, so categories remain readable for a much wider audience at zero extra effort.

Q: Write the call for one scatter panel per region, coloured by plan, revenue vs seats. A: sns.relplot(data=df, x="seats", y="mrr_usd", hue="plan", col="region") — figure-level, one panel per region, returns a FacetGrid you can style with g.set_titles(...).

Q: After import seaborn as sns; sns.set_theme(), your plain matplotlib charts look different. Bug or feature? A: Feature. set_theme writes matplotlib’s global rcParams, so all subsequent plots inherit the style. Reset with plt.style.use("default") or scope temporarily with with sns.axes_style("white"):.

Q: You need log-scaled y-axis, a custom annotation, and a legend outside the plot on a seaborn scatter. How? A: Use the axes-level scatterplot(..., ax=ax), keep the returned/passed ax, then use plain matplotlib: ax.set_yscale("log"), ax.annotate(...), and sns.move_legend(ax, "upper left", bbox_to_anchor=(1,1)). Seaborn sits on matplotlib, so the escape hatch is always there.

Q: When would you not use seaborn? A: For fully bespoke, non-statistical figures (drop to matplotlib), for interactive web charts with hover/zoom (plotly/bokeh/altair), or for millions of points that must stay interactive (datashader). Seaborn optimises the common statistical plot, not every plot.


Key takeaways

pythonseabornmatplotlibdata-visualizationstatistical-plotstidy-datapandasedaheatmapfacetingpalettesdata-science
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments