FacetGrid, pairplot, jointplot
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.
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 |
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 |
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=orrow=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 anAxes.
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.
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.
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.
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.
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 |
# 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.
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 ($)")
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.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.
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 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.
# 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")
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 |
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.
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
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)
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.
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)
# 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)
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 |
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.
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)
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.
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
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 |
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}, )
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 |
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 = 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) |
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
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)
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
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.
# 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.
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=...)
# 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 |
# 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)
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
objectbecause of a stray"N/A"will vanish. - Rows with missing values are dropped for the cells they affect. In
penguins,sexhas NaNs;pairplot(penguins, hue="sex")quietly analyses fewer rows thanlen(penguins).
# 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)
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 |
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.
# 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.
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.
# 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)
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. Useg.legend.set_title(...)org.legend.remove()instead. col_wrapdisables y-axis sharing behaviour you may expect. Withcol_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
# 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, acceptax=, return anAxes) and figure-level (own a wholeFigure, supportcol=/row=, sized byheight=/aspect=, return a Grid). The four faceting wrappers arerelplot,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.
FacetGridbuilt by hand — constructor, thenmap()ormap_dataframe(), thenadd_legend()— is the escape hatch for custom marks, layered panels and per-facet annotations.- Layout is controlled by
col_wrap(fold long rows; incompatible withrow=),sharex/sharey(comparability, passed viafacet_kwson figure-level calls),height/aspect(per-facet inches), andmargin_titlesplusset_titles()for labelling. pairplotshows every numeric pair; the diagonal holds distributions because a variable against itself is uninformative, andcorner=Truedrops the mirrored half. Always constrain withvars=— panels grow as k².PairGridaddsmap_upper,map_lowerandmap_diag, so each region of the matrix can carry a different mark or a computed annotation such as a correlation.jointplotpairs a central bivariate plot (kind=scatter, kde, hist, hex, reg, resid) with marginal distributions;JointGridwithplot_joint,plot_marginalsand the named axesax_joint/ax_marg_x/ax_marg_yallows 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(), andg.axes/g.axes_dictfor individual panels. - The recurring traps:
plt.title()hitting only the last facet,plt.figure(figsize=...)being ignored, deprecatedg.fig, the pairplot k² explosion, silently dropped non-numeric columns and NaN rows, misleading unshared axes, too many facets, missingtight_layout(), andload_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.