Skip to content

FacetGrid, pairplot, jointplot

Chapter 20 28 code blocks 9004 words

Every chart you have drawn so far has been a single set of axes: one x-axis, one y-axis, one rectangle of ink. This chapter is about the moment that stops being enough — when you want the same chart repeated across subsets of the data, or every pair of variables at once, or one relationship plus the distribution of each variable that feeds it. Seaborn calls these constructions grids, and they are the reason the library exists as something more than a nicer matplotlib.

Before any of the grid tools make sense, you need one concept: the split between axes-level and figure-level functions. It explains a dozen small confusions you have already met — why plt.title() sometimes lands on the wrong subplot, why figsize is ignored, why some functions accept ax= and others refuse. We start there.

20.1 Figure-level vs axes-level: the split that explains everything

Seaborn's plotting functions come in two kinds. They look similar in a script and behave completely differently.

An axes-level function draws onto a single matplotlib Axes — one panel. It does not create a figure of its own unless matplotlib has to make one implicitly. It accepts an ax= argument so you can point it at a specific panel of your own layout, and it returns that Axes object.

A figure-level function manages an entire matplotlib Figure. It creates the figure itself, decides how many panels it needs, draws an axes-level function into each one, adds a legend outside the panels, and returns a seaborn Grid object (FacetGrid, PairGrid, or JointGrid) that wraps the whole thing. It has no ax= parameter, because "which axes" is the question it exists to answer for you.

🧩 Analogy

An axes-level function is a painter you hand a single canvas to: "paint here." A figure-level function is a gallery curator: you describe the subject and the curator decides how many canvases are needed, hangs them on the wall in rows, labels each one, and puts a single shared key by the door. You cannot hand the curator a canvas — the curator owns the wall.

The pairs you must memorise

Every figure-level function is a thin wrapper that dispatches to a family of axes-level functions through a kind= argument. There are exactly four families.

Figure-level Axes-level functions it wraps Selected with Grid returned
sns.relplot() scatterplot, lineplot kind="scatter" (default), "line" FacetGrid
sns.displot() histplot, kdeplot, ecdfplot, rugplot kind="hist" (default), "kde", "ecdf" FacetGrid
sns.catplot() stripplot, swarmplot, boxplot, violinplot, boxenplot, pointplot, barplot, countplot kind="strip" (default), "swarm", "box", "violin", "boxen", "point", "bar", "count" FacetGrid
sns.lmplot() regplot — (no kind=; always regression) FacetGrid
sns.pairplot() scatterplot, histplot, kdeplot, regplot kind=, diag_kind= PairGrid
sns.jointplot() scatterplot, kdeplot, histplot, regplot, residplot kind="scatter" (default), "kde", "hist", "hex", "reg", "resid" JointGrid
RULE OF THUMBIf the function name ends in plot and describes a mark (scatter, box, hist), it is axes-level. If it ends in plot and describes a relationship or purpose (rel, dis, cat, lm, pair, joint), it is figure-level. The four "-plot of a category word" functions — relplot, displot, catplot, lmplot — are the faceting workhorses.

The consequences, one by one

The distinction is not academic. It changes six concrete things.

Question Axes-level (scatterplot) Figure-level (relplot)
What does it return? A matplotlib Axes A seaborn FacetGrid
Can I target an existing subplot? Yes — ax=axes[0, 1] No — it owns its figure
How do I set the size? plt.figure(figsize=(8, 5)) or plt.subplots(figsize=...) height= (inches per facet) and aspect= (width ÷ height)
Can I facet by a variable? No — no col=/row= Yes — col=, row=, col_wrap=
Where does the legend go? Inside the axes, often on top of the data Outside the panels, in the figure margin
Does it compose into a subplot mosaic? Yes, that is its job No — combining two figure-level plots requires drawing them separately
python18 lines
import seaborn as sns
import matplotlib.pyplot as plt

tips = sns.load_dataset("tips")

# --- axes-level: I control the figure, seaborn fills a panel ---
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
sns.scatterplot(data=tips, x="total_bill", y="tip", ax=axes[0])
sns.boxplot(data=tips, x="day", y="total_bill", ax=axes[1])
axes[0].set_title("Bill vs tip")       # titling a specific panel works
fig.suptitle("Two views of the tips data")
fig.tight_layout()

# --- figure-level: seaborn controls the figure, I describe the structure ---
g = sns.relplot(data=tips, x="total_bill", y="tip",
                col="day", col_wrap=2, hue="sex",
                height=3.2, aspect=1.1)
g.figure.suptitle("Bill vs tip by day", y=1.03)

How to choose

The decision is almost mechanical:

  • Use figure-level when the plot is the whole figure: exploration, a single chart in a notebook, and above all any time you want col= or row= faceting. You get shared axes, one shared legend, and consistent sizing for free.
  • Use axes-level when the plot is one component of a layout you are assembling yourself: a report figure with a scatter, a bar chart and a table caption; anything going into a matplotlib GridSpec; anything inside a dashboard framework that hands you an Axes.
💡 You can always drop down a level

A figure-level call is a convenience over axes-level calls. If a figure-level function will not do what you want, build the figure yourself with plt.subplots() and loop, calling the axes-level function with ax=. Nothing is lost — you are just doing the curator's job by hand.

📌 `height`/`aspect`, not `figsize`

Figure-level functions size a facet, not the figure. height=4, aspect=1.5 means each panel is 4 inches tall and 6 inches wide; the total figure size is then computed from the number of rows and columns. This is deliberate: adding a column should make the figure wider, not squash the existing panels.

20.2 FacetGrid and small multiples

The idea: small multiples

Edward Tufte named the technique small multiples: take one chart design and repeat it, unchanged, across slices of the data, laid out side by side at the same scale. Once the design is constant, the reader's attention is free to fall entirely on the differences between panels.

The reason this works is perceptual. Human vision judges position along a common scale extremely accurately, and judges colour and area poorly. Encoding "day of week" as five colours in one crowded scatter forces the eye to do the thing it is bad at. Encoding it as five panels, each with the same axis limits, lets the eye do the thing it is good at: compare where the cloud sits.

Colour encoding (one panel) Small multiples (five panels) +---------------------+ +------+ +------+ +------+ | x o * x o * | | Thur | | Fri | | Sat | | o * x o * x o | | .. | | .. | | .. | | * x o * x o | | ... | | . | | .... | +---------------------+ +------+ +------+ +------+ hard: separate 5 hues easy: compare 5 positions

Small multiples: the same chart repeated across subsets. The eye compares position across panels far more accurately than it compares colours in a crowded legend.

🧩 Analogy

Small multiples are a flip-book. Each page is drawn the same way, so when you riffle through, only the thing that changes appears to move. Overplotting everything in one panel with different colours is like printing every page of the flip-book on top of each other on a single sheet.

The easy path: col= and row=

Ninety per cent of the time you never touch FacetGrid directly. You pass col= and row= to a figure-level function, and it builds the grid for you.

python15 lines
tips = sns.load_dataset("tips")

# one column per day, rows split by smoker status
g = sns.relplot(data=tips, x="total_bill", y="tip",
                col="day", row="smoker",
                hue="sex", height=2.8, aspect=1.2)

# the same structure with a categorical mark
g = sns.catplot(data=tips, x="sex", y="total_bill",
                col="day", kind="box", col_wrap=2, height=3)

# and with distributions
penguins = sns.load_dataset("penguins")
g = sns.displot(data=penguins, x="flipper_length_mm",
                col="species", row="sex", kind="hist", bins=20, height=2.6)

`col="day", row="smoker" produces:

day=Thur day=Fri day=Sat day=Sun smoker=Yes +-----------+ +-----------+ +-----------+ +-----------+ | | | | | | | | +-----------+ +-----------+ +-----------+ +-----------+ smoker=No +-----------+ +-----------+ +-----------+ +-----------+ | | | | | | | | +-----------+ +-----------+ +-----------+ +-----------+ (x and y axes shared across every panel by default)`

The manual path: constructing a FacetGrid

Sometimes the wrapper cannot help you, because the mark you want to draw is not one of the ones relplot/catplot/displot knows about. Then you build the grid yourself. It is a two-step process: define the layout, then map a drawing function onto every panel.

python11 lines
import matplotlib.pyplot as plt

# step 1: define the layout — panels, but no data drawn yet
g = sns.FacetGrid(tips, col="day", row="smoker",
                  height=2.5, aspect=1.2, hue="sex")

# step 2: map a plotting function over each subset
g.map(sns.scatterplot, "total_bill", "tip", alpha=0.7)

# step 3: a legend, because map() does not add one
g.add_legend(title="Sex")

Three mapping methods exist and the difference matters.

Method What it passes to your function Use it for
g.map(func, "x", "y", ...) Positional vectors: func(x_series, y_series, **kws) matplotlib functions (plt.hist, plt.scatter) and simple seaborn calls
g.map_dataframe(func, x="a", y="b") The subset DataFrame as data= plus keyword column names Modern seaborn functions and any custom function that wants the whole subset
g.map_diag(...) / g.map_offdiag(...) (PairGrid only — see §20.5) Different marks on and off the diagonal
python8 lines
# map_dataframe: the function receives data=subset, so it can use any column
g = sns.FacetGrid(tips, col="time", height=3.5)
g.map_dataframe(sns.scatterplot, x="total_bill", y="tip", hue="size", palette="viridis")
g.add_legend()

# a plain matplotlib function via map(): gets raw arrays
g = sns.FacetGrid(tips, col="day", col_wrap=2, height=2.5)
g.map(plt.hist, "total_bill", bins=15, color="steelblue", edgecolor="white")

Custom functions on a grid

This is where manual FacetGrid earns its place: you can map anything, including a function you write. A mapped function must draw on the "current" axes — matplotlib's plt.gca() is already set to the right panel when your function is called.

python15 lines
import numpy as np

def bill_vs_tip_with_reference(data, **kws):
    # draw the data
    ax = plt.gca()
    ax.scatter(data["total_bill"], data["tip"], s=18, alpha=0.6, **kws)
    # overlay a reference line at exactly 15% tip
    xs = np.linspace(data["total_bill"].min(), data["total_bill"].max(), 2)
    ax.plot(xs, 0.15 * xs, ls="--", lw=1, color="0.3")
    # annotate the panel with its own sample size
    ax.text(0.05, 0.92, f"n = {len(data)}", transform=ax.transAxes, fontsize=9)

g = sns.FacetGrid(tips, col="day", col_wrap=2, height=2.8)
g.map_dataframe(bill_vs_tip_with_reference)
g.set_axis_labels("Total bill ($)", "Tip ($)")
WHEN TO GO MANUALUse relplot/catplot/displot unless you need: (a) a mark those wrappers do not offer, (b) two different marks layered in every panel, (c) a per-panel annotation such as n, R², or a reference line, or (d) a plotting function from outside seaborn.
⚠️ `map()` does not add a legend

Figure-level functions call add_legend() for you. A hand-built FacetGrid does not — if you passed hue= to the constructor and see no legend, you forgot g.add_legend(). Call it after all mapping, because it reads the artists that mapping created.

20.3 Grid options: col_wrap, sharing, size, titles

col_wrap — folding a long row

Faceting by a variable with eight levels produces eight panels in one row, each squeezed to a sliver. col_wrap=n wraps after n columns into a fresh row.

python4 lines
titanic = sns.load_dataset("titanic")

g = sns.catplot(data=titanic, x="sex", y="age", col="class", row=None,
                kind="violin", col_wrap=2, height=3)

`col="deck" without wrap (7 panels, unreadable): +--+ +--+ +--+ +--+ +--+ +--+ +--+

col="deck", col_wrap=3: +------+ +------+ +------+ | A | | B | | C | +------+ +------+ +------+ +------+ +------+ +------+ | D | | E | | F | +------+ +------+ +------+ +------+ | G | <- ragged last row is normal +------+`

⚠️ `col_wrap` and `row` are mutually exclusive

col_wrap cannot be used together with row=. The row dimension already determines the vertical layout, so there is nothing left to wrap. Passing both raises ValueError: Cannot userowwithcol_wrap``.

sharex / sharey — the honesty dial

By default every facet shares both axes: sharex=True, sharey=True. Shared axes are what makes small multiples comparable — the panels sit on a common scale, so a taller bar really is a bigger number. Turning sharing off gives each panel its own limits, which is right only when the panels measure different things.

python7 lines
# independent y per facet — each panel is scaled to its own data
g = sns.relplot(data=tips, x="total_bill", y="tip", col="day",
                col_wrap=2, height=2.6, facet_kws={"sharey": False})

# on FacetGrid directly the argument is top-level, not in facet_kws
g = sns.FacetGrid(tips, col="day", col_wrap=2, sharey=False, height=2.6)
g.map_dataframe(sns.scatterplot, x="total_bill", y="tip")
⚠️ Where does `sharey` go?

On sns.FacetGrid(...) it is a direct argument. On relplot/catplot/displot/lmplot it must be tunnelled through facet_kws={"sharey": False}, because those functions pass their own keywords to the underlying axes-level function. Passing sharey=False straight to relplot either errors or is silently forwarded to scatterplot, which does not want it.

height and aspect

height is the height of one facet in inches; aspect is its width divided by its height. So a facet is height * aspect inches wide. The total figure grows with the grid.

Settings Facet size Figure for a 3×2 grid
height=3, aspect=1 3 × 3 in ≈ 9 × 6 in
height=3, aspect=1.6 4.8 × 3 in ≈ 14.4 × 6 in
height=2, aspect=0.7 1.4 × 2 in ≈ 4.2 × 4 in
💡 Sensible starting values

height=3, aspect=1.2 for a handful of facets; drop to height=2.2 once you have more than nine panels. Use aspect > 1 for time series (wide panels reveal trend), aspect ≈ 1 for scatter (square panels avoid distorting the visual slope).

margin_titles and title templates

With both row= and col=, the default title on every panel reads smoker = Yes | day = Sat — accurate, repetitive, and wide. margin_titles=True moves the row label to the right-hand margin and keeps only the column label on top, so each label is written once.

python8 lines
g = sns.FacetGrid(tips, col="day", row="smoker",
                  margin_titles=True, height=2.4, aspect=1.2)
g.map_dataframe(sns.scatterplot, x="total_bill", y="tip")
g.set_titles(col_template="{col_name}", row_template="{row_name}")

# via a figure-level function
g = sns.relplot(data=tips, x="total_bill", y="tip", col="day", row="smoker",
                height=2.4, facet_kws={"margin_titles": True})

`margin_titles=False margin_titles=True

smoker=Yes|day=Thur smoker=Yes|day=Fri Thur Fri +----------------+ +---------------+ +--------+ +--------+ | | | | | | | | smoker=Yes +----------------+ +---------------+ +--------+ +--------+ smoker=No|day=Thur smoker=No|day=Fri +--------+ +--------+ +----------------+ +---------------+ | | | | smoker=No | | | | +--------+ +--------+ +----------------+ +---------------+`

The placeholders available to set_titles() are {col_name}, {row_name}, {col_var}, {row_var}. So g.set_titles("{col_var} = {col_name}") reproduces the default for a single-variable facet.

Controlling facet order and colour

python8 lines
g = sns.catplot(data=tips, x="sex", y="tip", col="day", kind="bar",
                col_order=["Thur", "Fri", "Sat", "Sun"],   # explicit panel order
                hue="smoker", hue_order=["No", "Yes"],
                palette="Set2", height=3)

# FacetGrid takes the same ordering arguments
g = sns.FacetGrid(tips, col="day", col_order=["Fri", "Sat", "Sun", "Thur"],
                  hue="time", hue_order=["Lunch", "Dinner"], height=2.8)
📌 Facet order follows category order

If the faceting column is a pandas Categorical with an order, seaborn respects it. Otherwise levels appear in order of first appearance for object dtype, or sorted for numeric. Setting col_order explicitly is the only way to be certain — and it also lets you drop levels by omitting them.

20.4 pairplot: every variable against every other

sns.pairplot() draws a matrix of bivariate plots: for a dataset with k numeric columns it produces a k × k grid where cell (i, j) shows column i against column j. It is the fastest way to see the shape of a new numeric dataset.

python4 lines
penguins = sns.load_dataset("penguins")

g = sns.pairplot(penguins)                       # all numeric columns
g = sns.pairplot(penguins, hue="species")        # colour by group

Every numeric variable against every other, with distributions on the diagonal. This one chart is often the whole first pass of an EDA.

`pairplot(penguins, vars=["bill_length_mm", "bill_depth_mm", "flipper_length_mm"])

bill_len bill_depth flipper +-----------+ +-----------+ +-----------+ bill_len | /\ hist | | scatter | | scatter | +-----------+ +-----------+ +-----------+ bill_depth| scatter | | /\ hist | | scatter | +-----------+ +-----------+ +-----------+ flipper | scatter | | scatter | | /\ hist | +-----------+ +-----------+ +-----------+ diagonal = univariate; off-diagonal mirrors across it`

Why the diagonal is a distribution

Cell (i, i) would plot a variable against itself. That is always a perfect straight line at 45°, identical for every dataset, and therefore carries no information. Seaborn spends that space on something useful instead: the univariate distribution of that variable — a histogram by default, a KDE if you ask.

Why corner=True halves the ink

Cell (i, j) and cell (j, i) show the same two variables with the axes swapped. They are mirror images: no new information, just a reflection. corner=True drops the upper triangle, leaving a lower-triangular matrix. For six variables this cuts 36 panels to 21, and each surviving panel gets more space.

g = sns.pairplot(penguins, hue="species", corner=True)

corner=False (k=4 -> 16 panels) corner=True (k=4 -> 10 panels) +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+ +--+

The parameters worth knowing

ParameterValuesEffect

varslist of column namesRestrict the matrix to these columns — the single most important argument x_vars, y_varslistsA rectangular, non-square matrix (e.g. 3 predictors against 1 target) huecolumn nameColour by group; also splits the diagonal into per-group distributions kind``"scatter", "kde", "hist", "reg"Mark used off the diagonal diag_kind``"auto", "hist", "kde", NoneMark on the diagonal; "auto" means KDE when hue is set, histogram otherwise corner``True/FalseDrop the redundant upper triangle markersstr or listMarker shape per hue level — useful for print and colour-blind readers plot_kws, diag_kwsdictKeywords forwarded to the off-diagonal / diagonal functions height, aspectfloatSize of each cell dropnaboolDrop missing values (behaviour is per-plot in modern versions)

python26 lines
# restrict the variables and shape the marks
g = sns.pairplot(
    penguins,
    vars=["bill_length_mm", "bill_depth_mm", "flipper_length_mm", "body_mass_g"],
    hue="species",
    diag_kind="kde",
    corner=True,
    markers=["o", "s", "D"],
    plot_kws={"alpha": 0.6, "s": 25, "edgecolor": "none"},
    diag_kws={"fill": True, "common_norm": False},
    height=2.0,
)

# a rectangular matrix: three predictors against one outcome
g = sns.pairplot(penguins,
                 x_vars=["bill_length_mm", "bill_depth_mm", "flipper_length_mm"],
                 y_vars=["body_mass_g"],
                 hue="sex", height=3, aspect=0.9)

# regression in every cell — useful for spotting linear structure fast
iris = sns.load_dataset("iris")
g = sns.pairplot(iris, kind="reg", diag_kind="hist",
                 plot_kws={"line_kws": {"color": "crimson"}})

# density instead of points when there are too many rows to see through
g = sns.pairplot(iris, kind="kde", corner=True)
💡 First move on any new numeric dataset

sns.pairplot(df.select_dtypes("number").sample(min(len(df), 2000)), corner=True). Restricting to numeric columns avoids surprises, sampling keeps it fast, and corner=True keeps it readable. Then use vars= to zoom in on what looked interesting.

20.5 PairGrid: full control over the matrix

pairplot() is a wrapper around PairGrid that fills every region with one mark. PairGrid lets you choose a different mark for the upper triangle, the lower triangle and the diagonal — which is exactly how you get a matrix that is dense with information rather than symmetric and half-wasted.

Method Region filled
g.map(func, **kws) Every cell, diagonal included
g.map_diag(func, **kws) The diagonal only
g.map_offdiag(func, **kws) Everything except the diagonal
g.map_upper(func, **kws) Upper triangle only
g.map_lower(func, **kws) Lower triangle only
python8 lines
g = sns.PairGrid(penguins, vars=["bill_length_mm", "bill_depth_mm",
                                 "flipper_length_mm", "body_mass_g"],
                 hue="species", height=2.0, diag_sharey=False)

g.map_upper(sns.scatterplot, s=15, alpha=0.6)   # points above
g.map_lower(sns.kdeplot, levels=4, linewidths=1) # contours below
g.map_diag(sns.histplot, kde=True, element="step")
g.add_legend(title="Species")

PairGrid gives full control: raw points above the diagonal, smoothed density below, distributions along it.

v1 v2 v3 +--------+ +------+ +------+ v1 | hist | |scatter| |scatter| <- upper: raw points +--------+ +------+ +------+ v2 | kde | | hist | |scatter| +--------+ +------+ +------+ v3 | kde | | kde | | hist | <- lower: smoothed density +--------+ +------+ +------+ diagonal: univariate

A custom summary in the upper triangle

Because map_upper accepts any function, a common professional pattern is to put the numeric correlation where the redundant scatter used to be.

python16 lines
import numpy as np

def annotate_corr(x, y, **kws):
    ax = plt.gca()
    ok = np.isfinite(x) & np.isfinite(y)      # drop NaN pairs first
    r = np.corrcoef(x[ok], y[ok])[0, 1]
    ax.set_axis_off()
    ax.text(0.5, 0.5, f"r = {r:.2f}", ha="center", va="center",
            fontsize=13, transform=ax.transAxes)

g = sns.PairGrid(penguins.dropna(), vars=["bill_length_mm", "bill_depth_mm",
                                          "flipper_length_mm", "body_mass_g"],
                 height=1.9)
g.map_lower(sns.scatterplot, s=12, alpha=0.5)
g.map_diag(sns.histplot, color="0.4")
g.map_upper(annotate_corr)
📌 `diag_sharey=False`

Diagonal panels show counts or densities, not the variable's own scale, so sharing the y-axis with the scatter rows squashes them. PairGrid(..., diag_sharey=False) frees them. pairplot() sets this for you; a hand-built PairGrid does not.

20.6 jointplot: a relationship plus its margins

A scatter of two variables tells you about their joint behaviour but hides each variable's own distribution. sns.jointplot() puts the bivariate plot in a large central panel and a univariate plot of each variable in a thin panel along the corresponding edge.

python2 lines
g = sns.jointplot(data=penguins, x="bill_length_mm", y="bill_depth_mm",
                  hue="species", height=5)

jointplot pairs a main plot with marginal distributions on both axes. Change kind to suit the data density — hex handles overplotting far better than scatter.

+--------------------+ | marginal x | <- distribution of x +--------------------+ +---+ +--------------------+ | m | | | | a | | joint plot | | r | | (the main axes) | | g | | | | y | +--------------------+ +---+ ^ distribution of y, rotated 90 degrees

kind= options

kindCentral panelBest for

"scatter" (default)scatterplotSmall to moderate n; every point visible "kde"``kdeplot contoursShowing density shape; smooth, continuous data "hist"2-D histogram (rectangular bins)Large n where points overplot "hex"Hexagonal binningLarge n; hexagons tile without the visual artefacts of a square grid "reg"``regplot: scatter + fitted line + CIAssessing a linear relationship "resid"``residplot: residuals from a linear fitDiagnosing whether a linear fit is adequate

python12 lines
tips = sns.load_dataset("tips")

sns.jointplot(data=tips, x="total_bill", y="tip", kind="reg",
              height=5, marginal_kws={"bins": 25, "fill": True})

sns.jointplot(data=tips, x="total_bill", y="tip", kind="hex",
              color="#4c72b0", height=5)

sns.jointplot(data=penguins, x="flipper_length_mm", y="body_mass_g",
              kind="kde", fill=True, thresh=0.05, levels=8, height=5)

sns.jointplot(data=tips, x="total_bill", y="tip", kind="resid", height=5)

Passing keywords to each layer

jointplot draws two different things, so it needs two keyword channels.

Argument Goes to
Top-level keywords (alpha=, s=, …) The joint (central) plotting function
joint_kws={...} The joint plotting function, explicitly
marginal_kws={...} Both marginal plotting functions
ratio= Joint-axes size ÷ marginal-axes size (default 5)
space= Gap in inches between joint and marginal axes
marginal_ticks= True shows counts/density ticks on the marginals
python6 lines
g = sns.jointplot(
    data=penguins, x="flipper_length_mm", y="body_mass_g", hue="species",
    kind="scatter", height=5.5, ratio=4, space=0.15,
    joint_kws={"s": 28, "alpha": 0.7, "edgecolor": "none"},
    marginal_kws={"fill": True, "common_norm": False, "alpha": 0.4},
)
⚠️ `kind="reg"` and `kind="hex"` reject `hue`

hue= works with "scatter", "kde" and "hist". It does not work with "reg", "resid" or "hex", because a hexbin has one colour scale for counts and the regression path fits a single model. If you need grouped regressions with margins, build a JointGrid and call sns.lmplot-style logic yourself, or use lmplot(hue=...) without margins.

20.7 JointGrid: full control over the three panels

JointGrid creates the three empty axes and lets you fill them independently. The pattern mirrors PairGrid.

Member Meaning
g.plot_joint(func, **kws) Draw into the central axes
g.plot_marginals(func, **kws) Draw into both marginal axes
g.plot(joint_func, marginal_func) Shorthand for both at once
g.ax_joint The central Axes
g.ax_marg_x, g.ax_marg_y The top and right Axes
g.refline(x=..., y=...) Reference lines across joint and marginal axes
python19 lines
g = sns.JointGrid(data=penguins, x="flipper_length_mm", y="body_mass_g",
                  hue="species", height=5.5, ratio=4)

g.plot_joint(sns.scatterplot, s=30, alpha=0.7)
g.plot_marginals(sns.boxplot)            # boxes instead of histograms
g.refline(x=200, y=4000)                 # reference lines in all three panels
g.set_axis_labels("Flipper length (mm)", "Body mass (g)")

# layering two marks in the joint panel — impossible with jointplot
g = sns.JointGrid(data=tips, x="total_bill", y="tip", height=5)
g.plot_joint(sns.kdeplot, fill=True, cmap="Blues", levels=8, thresh=0.05)
g.plot_joint(sns.scatterplot, s=14, color="0.2", alpha=0.5)
g.plot_marginals(sns.histplot, kde=True, color="0.4")

# different marks on the two margins, reached through the axes objects
g = sns.JointGrid(data=tips, x="total_bill", y="tip", height=5)
g.plot_joint(sns.scatterplot, s=18)
sns.histplot(data=tips, x="total_bill", ax=g.ax_marg_x, bins=25, color="0.5")
sns.boxplot(data=tips, y="tip", ax=g.ax_marg_y, color="0.8", width=0.4)
JOINTPLOT VS JOINTGRIDjointplot = one mark in the centre, one kind of margin, chosen by kind=. JointGrid = as many layers as you like in each of three independently addressable panels. Reach for the grid the moment you want to layer.

20.8 Customising a returned grid object

Every figure-level function returns a grid, and you should assign it — conventionally to g. The grid is your handle on the whole figure, and its methods are chainable because each returns self.

Call Effect
g.figure The underlying matplotlib Figure (use g.figure.suptitle(...))
g.axes Array of Axes; 2-D for a row×col grid, 1-D when col_wrap is used
g.axes_dict Dict keyed by facet value — g.axes_dict["Sat"] or g.axes_dict[("Yes", "Sat")]
g.set_axis_labels(x, y) Label the outer axes once, not every panel
g.set_titles(template) Retitle every facet from a template
g.set(xlim=, ylim=, xscale=, ...) Apply any Axes.set() keyword to every panel
g.set_xticklabels(...) / g.set_yticklabels(...) Tick labels on every panel (accepts rotation=)
g.despine(left=True) Remove spines across the grid
g.add_legend(title=..., bbox_to_anchor=...) Add or reposition the shared legend
g.tight_layout() Resolve overlapping labels and titles
g.savefig(path, dpi=..., bbox_inches="tight") Save the whole figure
g.refline(x=, y=) Reference line on every facet (FacetGrid, JointGrid)
python11 lines
g = sns.relplot(data=tips, x="total_bill", y="tip", col="day",
                col_wrap=2, hue="sex", height=3, aspect=1.1)

g.set_axis_labels("Total bill ($)", "Tip ($)")
g.set_titles("{col_name}")
g.set(xlim=(0, 60), ylim=(0, 12))
g.despine(left=False)
g.refline(y=tips["tip"].mean())
g.figure.suptitle("Tipping behaviour by day of week", y=1.03, fontsize=14)
g.tight_layout()
g.savefig("tips_by_day.png", dpi=200, bbox_inches="tight")

Reaching one specific panel

python20 lines
g = sns.relplot(data=tips, x="total_bill", y="tip", col="day",
                row="smoker", height=2.5)

# by position: axes is a 2-D numpy array of Axes
g.axes[0, 2].set_facecolor("#fff3cd")

# by name: far more readable and robust to reordering
ax = g.axes_dict[("Yes", "Sat")]
ax.annotate("busiest service", xy=(40, 8), fontsize=9)

# iterate over every panel with its key
for (smoker, day), ax in g.axes_dict.items():
    ax.grid(True, alpha=0.3)
    if day == "Sun":
        ax.set_facecolor("#f5f5f5")

# with col_wrap, axes is 1-D — use axes.flat to be safe either way
g2 = sns.relplot(data=tips, x="total_bill", y="tip", col="day", col_wrap=2)
for ax in g2.axes.flat:
    ax.axhline(3, color="0.6", ls=":", lw=1)
💡 `axes_dict` beats index arithmetic

Positional indexing breaks the moment you change col_order or add a row=. g.axes_dict is keyed by the facet value (a tuple when both row and col are set), so the code keeps saying what you meant.

Method chaining

python7 lines
g = (sns.catplot(data=titanic, x="class", y="age", col="sex",
                 kind="violin", height=3.5)
       .set_axis_labels("Passenger class", "Age (years)")
       .set_titles("{col_name}")
       .set(ylim=(0, 80))
       .despine(left=True))
g.figure.suptitle("Age distribution by class and sex", y=1.04)

20.9 Gotchas

plt.title() titles only the last subplot

This is the single most common grid mistake. plt.title() acts on matplotlib's current axes, and after a figure-level call the current axes is whichever panel was drawn last — usually the bottom-right one. Your title appears on one small panel instead of over the figure.

python8 lines
# WRONG — the title lands on the last facet only
sns.relplot(data=tips, x="total_bill", y="tip", col="day", col_wrap=2)
plt.title("Tips by day")

# RIGHT — title the figure through the grid
g = sns.relplot(data=tips, x="total_bill", y="tip", col="day", col_wrap=2)
g.figure.suptitle("Tips by day", y=1.03)
g.tight_layout()

The same applies to plt.xlabel(), plt.ylabel(), plt.xlim() and friends: on a grid use g.set_axis_labels() and g.set(xlim=...), which touch every panel.

⚠️ `y=1.02` on `suptitle`

By default suptitle is placed inside the figure and can collide with the top row of facet titles. Passing y=1.02 or 1.03 lifts it just above the figure box; follow with g.tight_layout() and save with bbox_inches="tight" so the raised title is not cropped out of the file.

Figure-level functions ignore plt.figure(figsize=...)

python10 lines
# WRONG — the figure created here is discarded; relplot makes its own
plt.figure(figsize=(14, 6))
sns.relplot(data=tips, x="total_bill", y="tip", col="day")

# RIGHT — size the facets
g = sns.relplot(data=tips, x="total_bill", y="tip", col="day",
                height=3, aspect=1.2)          # ≈ 14.4 x 3 in total

# LAST RESORT — resize after the fact
g.figure.set_size_inches(14, 6)

You will often see an empty extra figure appear in a notebook after this mistake — that is the discarded plt.figure().

g.fig is deprecated; use g.figure

Seaborn 0.11 and earlier exposed the matplotlib figure as g.fig. Since 0.11.2 the attribute is g.figure, matching matplotlib's own naming, and g.fig emits a deprecation warning. Old tutorials are full of g.fig.suptitle(...); write g.figure.suptitle(...).

pairplot on a wide DataFrame: the n² explosion

A pairplot of k numeric columns draws k² panels. Twenty columns is 400 panels — minutes of rendering, megabytes of output, and nothing legible.

Numeric columns Panels (full) Panels (corner=True) Verdict
4 16 10 Ideal
8 64 36 Getting cramped
15 225 120 Unreadable
40 1600 820 Do not attempt
python4 lines
# pick variables deliberately — e.g. the strongest correlates of the target
num = penguins.select_dtypes("number")
top = num.corr()["body_mass_g"].abs().sort_values(ascending=False).index[:5]
g = sns.pairplot(penguins, vars=list(top), hue="species", corner=True, height=1.9)
💡 A heatmap first, a pairplot second

For more than about eight numeric columns, screen with sns.heatmap(df.corr(numeric_only=True)) to find the interesting pairs, then pairplot only those. The heatmap scales linearly in readability where the pairplot scales quadratically.

pairplot silently drops columns and rows

Two silent filters run before anything is drawn:

  • Non-numeric columns are excluded from the matrix. A dataset of ten columns of which six are strings produces a 4×4 grid, with no warning. If a column you expected is missing, check its dtype — a numeric column read as object because of a stray "N/A" will vanish.
  • Rows with missing values are dropped for the cells they affect. In penguins, sex has NaNs; pairplot(penguins, hue="sex") quietly analyses fewer rows than len(penguins).
python8 lines
# make the filtering explicit rather than silent
print(penguins.dtypes)
print(penguins.isna().sum())

clean = penguins.dropna(subset=["bill_length_mm", "bill_depth_mm",
                                "flipper_length_mm", "body_mass_g", "species"])
print(f"{len(penguins)} rows -> {len(clean)} rows used")
g = sns.pairplot(clean, hue="species", corner=True)
Output
species               object
island                object
bill_length_mm       float64
bill_depth_mm        float64
flipper_length_mm    float64
body_mass_g          float64
sex                   object
dtype: object
species               0
island                0
bill_length_mm        2
bill_depth_mm         2
flipper_length_mm     2
body_mass_g           2
sex                  11
dtype: int64
344 rows -> 342 rows used

The sharey trap, in both directions

Axis sharing is a claim about comparability, and either setting can lie.

Setting Risk When it is right
sharey=True (default) A facet with a small range becomes a flat line squashed at the bottom; real within-facet structure disappears Panels measure the same quantity and you want to compare levels
sharey=False Every panel is rescaled to fill its box, so wildly different groups look identical; the reader compares shapes across incomparable scales Panels measure different quantities, or only the within-panel shape matters
⚠️ If you unshare, say so

When you set sharey=False, the axis tick labels are the only signal that scales differ, and readers skip tick labels. Add it to the title or a caption: "note: y-axis scaled independently per panel". A chart that misleads a careful reader is worse than no chart.

Too many facets

Faceting on a high-cardinality column — customer ID, city, product SKU — produces hundreds of postage-stamp panels. Fixes, in order of preference: aggregate the variable into a handful of meaningful levels; keep the top n levels and bucket the rest as "Other"; use col_wrap plus a smaller height; or drop faceting and use hue with transparency.

python5 lines
# collapse a long tail before faceting
top5 = tips["size"].value_counts().index[:5]
sub = tips[tips["size"].isin(top5)]
g = sns.catplot(data=sub, x="day", y="tip", col="size",
                kind="box", col_wrap=3, height=2.4)

Overlapping labels without tight_layout()

Long tick labels, rotated labels, or a raised suptitle will collide with neighbouring panels. Call g.tight_layout() as the last step before display or saving.

python6 lines
g = sns.catplot(data=titanic, x="embark_town", y="fare", col="class",
                kind="bar", height=3.2)
g.set_xticklabels(rotation=45, ha="right")
g.set_axis_labels("Port of embarkation", "Fare (£)")
g.figure.suptitle("Fare by port and class", y=1.04)
g.tight_layout()

sns.load_dataset() needs internet

sns.load_dataset("tips") downloads a CSV from the seaborn-data repository on GitHub. Offline — a locked-down lab machine, an exam room, an air-gapped network — it raises a URL or timeout error, and every example in this chapter fails on line one.

python14 lines
# one-off, while you still have a connection: cache the CSVs locally
import seaborn as sns, os
os.makedirs("seaborn-data", exist_ok=True)
for name in ["tips", "penguins", "iris", "titanic"]:
    sns.load_dataset(name).to_csv(f"seaborn-data/{name}.csv", index=False)

# afterwards, offline: point seaborn at the cache
import seaborn as sns
sns.load_dataset("tips", data_home="seaborn-data", cache=True)

# or bypass seaborn entirely — every function takes any DataFrame
import pandas as pd
tips = pd.read_csv("seaborn-data/tips.csv")
sns.relplot(data=tips, x="total_bill", y="tip", col="day", col_wrap=2)
📌 Nothing in seaborn requires its own datasets

load_dataset() is a teaching convenience, not part of the plotting API. Any pandas.DataFrame works identically. Cache the four CSVs once and the whole guide runs offline.

Two more small ones

  • Legends drawn twice. Calling g.add_legend() on a grid produced by a figure-level function adds a second legend, because one already exists. Use g.legend.set_title(...) or g.legend.remove() instead.
  • col_wrap disables y-axis sharing behaviour you may expect. With col_wrap, seaborn cannot use matplotlib's normal row/column sharing machinery for the outer labels, so tick labels appear on more panels than in a true grid. This is cosmetic, but it is why a wrapped grid looks busier.

20.10 Practice set

python54 lines
# Load these once at the top of your notebook.
import seaborn as sns
import matplotlib.pyplot as plt
tips     = sns.load_dataset("tips")
penguins = sns.load_dataset("penguins")
iris     = sns.load_dataset("iris")
titanic  = sns.load_dataset("titanic")

# 1. FIGURE VS AXES
#    Draw a scatter of total_bill vs tip twice: once with sns.scatterplot into
#    the left panel of a 1x2 matplotlib figure you create yourself, and once
#    with sns.relplot. Print type() of what each call returns and explain in a
#    comment why only one of them accepted ax=.

# 2. SMALL MULTIPLES
#    Facet the tips scatter by day (col=) and smoker (row=), colouring by sex.
#    Then draw the same information in ONE panel using hue and style only.
#    Write two sentences on which is easier to read and why.

# 3. COL_WRAP AND SIZING
#    Plot penguin flipper_length_mm distributions faceted by island AND sex
#    using displot. Make each facet 2.5 inches tall and 1.3 times as wide.
#    Then redo it with col="species" and col_wrap=2.

# 4. MANUAL FACETGRID
#    Build a FacetGrid over tips with col="day", col_wrap=2. Map a custom
#    function that (a) scatters total_bill vs tip, (b) draws a dashed 15%
#    reference line, and (c) prints the panel's mean tip percentage in the
#    top-left corner. Add axis labels and a figure title.

# 5. PAIRPLOT DISCIPLINE
#    Run sns.pairplot(penguins) with no other arguments and count the panels.
#    Then produce a corner pairplot of only the four body-measurement columns,
#    hue by species, KDE diagonal, alpha 0.6. Report how many rows were
#    dropped for missing values.

# 6. PAIRGRID REGIONS
#    On the iris dataset, build a PairGrid with scatter in the lower triangle,
#    KDE contours in the upper triangle, and a histogram on the diagonal,
#    coloured by species. Add a legend titled "Species".

# 7. JOINTPLOT AND JOINTGRID
#    Draw total_bill vs tip four ways with jointplot: scatter, hex, kde, reg.
#    Then reproduce the kde version with JointGrid, adding a scatter layer on
#    top of the contours and box plots on both margins.

# 8. FIX THE BROKEN PLOT
#    The code below has four bugs. Find and fix all of them.
#        plt.figure(figsize=(12, 4))
#        g = sns.catplot(data=titanic, x="sex", y="fare", col="class",
#                        row="embark_town", col_wrap=2, kind="bar",
#                        sharey=False)
#        plt.title("Fare by class")
#        g.fig.savefig("out.png")

20.11 Interview questions

Q: What is the difference between an axes-level and a figure-level function in seaborn, and how do you tell which is which?

An axes-level function (scatterplot, boxplot, histplot, regplot) draws onto a single matplotlib Axes, accepts ax=, and returns that Axes. A figure-level function (relplot, catplot, displot, lmplot, pairplot, jointplot) creates and owns an entire Figure, supports col=/row= faceting, places one legend outside the panels, is sized with height=/aspect= rather than figsize=, and returns a FacetGrid, PairGrid or JointGrid. Practically: if it takes ax= it is axes-level. Figure-level functions are wrappers that dispatch to axes-level ones via kind=.

Q: You want a scatter and a box plot side by side in one figure. Which seaborn functions do you use, and why not relplot and catplot?

Use the axes-level pair: fig, axes = plt.subplots(1, 2), then sns.scatterplot(..., ax=axes[0]) and sns.boxplot(..., ax=axes[1]). relplot and catplot each create their own figure and have no ax= parameter, so calling both would produce two separate figures. Figure-level functions cannot be composed into a shared layout; that is the trade for the faceting and legend handling they give you.

Q: A colleague writes plt.title("...") after a relplot with four facets and the title appears on one small panel. Explain and fix.

plt.title() acts on matplotlib's current axes, which after a faceted call is the last panel drawn. The figure has four axes and pyplot's state points at one of them. The fix is to title the figure through the returned grid: g.figure.suptitle("...", y=1.02), followed by g.tight_layout(). Note g.figure, not the deprecated g.fig. The same logic applies to plt.xlabel/plt.xlim, replaced by g.set_axis_labels() and g.set(xlim=...).

Q: Why does the diagonal of a pairplot show a distribution, and what does corner=True do?

The diagonal cell would plot a variable against itself, which is always the identity line and carries no information, so seaborn uses the space for the variable's univariate distribution (histogram, or KDE when hue is set). corner=True removes the upper triangle: cell (i, j) and cell (j, i) show the same pair with axes swapped, so half the matrix is a mirror image. Dropping it cuts k² panels to k(k+1)/2, reduces render time and gives each remaining panel more space.

Q: When would you build a FacetGrid by hand instead of using relplot or catplot?

When you need something the wrappers do not expose: a mark that is not in their kind= list (including plain matplotlib functions or a third-party plotting call); two or more layers drawn in every panel; or a per-facet annotation computed from that facet's subset, such as n, R², a group mean or a reference line. The pattern is g = sns.FacetGrid(df, col=..., row=...), then g.map_dataframe(my_func, ...), then g.add_legend() — the legend is not automatic on a hand-built grid.

Q: What is the difference between g.map() and g.map_dataframe()? map() passes the named columns as positional vectors: your function receives raw arrays. map_dataframe() passes the facet's subset as data= plus the column names as keyword arguments, so the function can reach any column in that subset — not only the ones you named. Modern seaborn functions and custom functions that need extra columns (for a per-panel statistic, or a secondary grouping) require map_dataframe().

Q: Explain sharey=True versus sharey=False across facets and the risk of each. sharey=True (the default) puts all panels on one scale, so positions are directly comparable — this is what makes small multiples work. The risk is that a facet with a narrow range is flattened and its internal structure vanishes. sharey=False rescales each panel to its own data, revealing within-panel shape, but it makes groups with wildly different magnitudes look identical, which actively misleads. Use unshared axes only when the panels are not meant to be compared on level, and state it in the title or caption. On figure-level functions the setting goes in facet_kws={"sharey": False}.

Q: How do you reach and modify a single subplot inside a returned grid?

Through g.axes or g.axes_dict. g.axes is a numpy array of Axes — 2-D for a row×col grid, 1-D when col_wrap is used, so g.axes.flat is the safe way to iterate. g.axes_dict is keyed by facet value: g.axes_dict["Sat"], or g.axes_dict[("Yes", "Sat")] when both row and col are set. Prefer axes_dict, because positional indexing silently points at the wrong panel if col_order changes. For JointGrid the panels are named directly: g.ax_joint, g.ax_marg_x, g.ax_marg_y.

Q: sns.pairplot(df) on a 12-column DataFrame produced a 5×5 grid. What happened?

Pairplot uses only numeric columns and silently ignores the rest, so seven columns were non-numeric — either genuinely categorical, or numeric data parsed as object because of stray tokens such as "N/A", thousands separators or currency symbols. Check df.dtypes and coerce with pd.to_numeric(..., errors="coerce") if a column should have been included. Separately, rows with missing values are dropped from the affected cells, so the panel sample size can be smaller than len(df).

20.12 Summary

  • Seaborn functions split into axes-level (draw on one Axes, accept ax=, return an Axes) and figure-level (own a whole Figure, support col=/row=, sized by height=/aspect=, return a Grid). The four faceting wrappers are relplot, displot, catplot, lmplot.
  • Small multiples repeat one chart design across data subsets on a shared scale, converting a hard colour-comparison task into an easy position-comparison task.
  • FacetGrid built by hand — constructor, then map() or map_dataframe(), then add_legend() — is the escape hatch for custom marks, layered panels and per-facet annotations.
  • Layout is controlled by col_wrap (fold long rows; incompatible with row=), sharex/sharey (comparability, passed via facet_kws on figure-level calls), height/aspect (per-facet inches), and margin_titles plus set_titles() for labelling.
  • pairplot shows every numeric pair; the diagonal holds distributions because a variable against itself is uninformative, and corner=True drops the mirrored half. Always constrain with vars= — panels grow as k².
  • PairGrid adds map_upper, map_lower and map_diag, so each region of the matrix can carry a different mark or a computed annotation such as a correlation.
  • jointplot pairs a central bivariate plot (kind= scatter, kde, hist, hex, reg, resid) with marginal distributions; JointGrid with plot_joint, plot_marginals and the named axes ax_joint/ax_marg_x/ax_marg_y allows layering.
  • Customise through the returned grid: g.figure.suptitle(..., y=1.02), g.set_axis_labels(), g.set_titles("{col_name}"), g.set(xlim=...), g.tight_layout(), g.savefig(), and g.axes/g.axes_dict for individual panels.
  • The recurring traps: plt.title() hitting only the last facet, plt.figure(figsize=...) being ignored, deprecated g.fig, the pairplot k² explosion, silently dropped non-numeric columns and NaN rows, misleading unshared axes, too many facets, missing tight_layout(), and load_dataset() needing a network unless you cache the CSVs.

Next: Chapter 21 covers styling — themes, palettes, context and saving figures.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents