Seaborn gotchas & interview
This chapter closes Part II the way Chapter 13 closed Part I: nothing new is introduced. Everything here is a compression of Chapters 14 to 22 into the four things that survive after the syntax has been forgotten — the mistakes that quietly produce wrong charts, the procedure for choosing a chart at all, the difference between a chart that informs and a chart that misleads, and a question bank to revise from.
The order matters. A student who can name every argument of sns.histplot() but cannot say what the shaded band on a lineplot means has learned the library and missed the subject. The gotcha catalogue below is ranked by damage for exactly that reason: the entries at the top produce charts that look completely normal and say something false.
23.1 The gotcha catalogue
Twenty-six failures, ranked by how much damage they do. "Damage" here means the product of two things: how likely the mistake is to survive into a figure someone acts on, and how wrong the resulting conclusion is. A crash is low damage — it stops you. A plot that renders beautifully and reverses a sign is the worst case there is.
Rows 1 to 8 are the ones that change conclusions. Rows 9 to 18 mislead the reader without changing what the code computed. Rows 19 to 26 are friction: errors, blank files, deprecations and unreadable output. The Ch. column points at the chapter where the mechanism is explained in full.
| # | Gotcha | What actually happens | Fix | Ch. |
|---|---|---|---|---|
| 1 | barplot read as a count or a total |
The bar height is the mean of y within the category, and the cap on top is a bootstrapped 95% CI of that mean. "Sales by region" drawn this way shows average sale per transaction; the ranking can be the exact opposite of total sales. |
countplot for row counts, barplot(estimator="sum") for totals. Always write the statistic into the y-label: plt.ylabel("mean total bill"). |
17 |
| 2 | lineplot aggregates silently and the band is read as data |
When x repeats, a line cannot pass through every y, so seaborn plots estimator="mean" per unique x with a bootstrap CI ribbon. Students describe twelve means with twelve intervals as "the data". |
Check df["x"].duplicated().any() first. Then use estimator=None, a hue per series, a genuinely unique x, or state the aggregation in the caption. |
15 |
| 3 | The regression band read as a prediction interval | The ribbon around a regplot/lmplot line is a CI for the mean of y at each x. Roughly half the observations fall outside it, and that is expected. Its width shrinks like 1/√n even when the fitted form is wrong. |
Say "confidence interval for the line" out loud. For a real prediction interval leave seaborn: statsmodels model.get_prediction(X).summary_frame() gives obs_ci_*. |
19 |
| 4 | Missing values dropped without a word | Every seaborn function removes rows where a mapped variable is NaN. titanic["age"] is missing for 177 of 891 passengers; penguins["sex"] for 11. A facet can be fitted on 30 rows next to one fitted on 300, with no visual difference. |
df.isna().sum() before plotting, every time. Report n per group in the legend or as a per-facet annotation, and drop rows explicitly so the count is visible in your code. |
15–20 |
| 5 | The bin count decides the story | Penguin flipper length is unimodal at bins=3, cleanly bimodal at bins=12, and a picket fence at bins=120 — same 344 rows. The default bins="auto" is a formula that assumes a smooth unimodal density, which is the assumption that fails when you most need the plot. |
Draw at the default, at half and at double; keep only features that survive all three. Prefer binwidth= (interpretable and stable across facets) and state it in the caption. Cross-check with an ECDF, which has no tuning parameter. |
16 |
| 6 | KDE bandwidth smooths a real mode out of existence | Scott's rule is optimal for one Gaussian. On two overlapping ones it picks a bandwidth wider than either mode and returns a single confident hump. A default KDE will let you report "unimodal" about genuinely bimodal data. | Always redraw with bw_adjust=0.5 and against a histogram before writing the word "unimodal". A violin inherits the same problem, since it is a mirrored KDE. |
16, 17 |
| 7 | A boxplot hides a second peak entirely | Values clustered at 20 and at 80 give a median of 50 and ordinary-looking quartiles — while no observation is anywhere near the median. Uniform, bimodal, normal and skewed samples can be built to share one five-number summary. | Never present a boxplot of an unfamiliar variable without having seen its distribution some other way: violin, strip overlay, or histogram. boxplot(showfliers=False) plus stripplot on the same axes is the standard combination. |
17 |
| 8 | df.corr() fails or misleads on non-numeric columns |
pandas 2.0 changed the default to numeric_only=False, so penguins.corr() now raises ValueError: could not convert string to float: 'Adelie'. pandas 1.x dropped such columns silently. The same change hit mean, sum and median. |
df.corr(numeric_only=True), or the more self-documenting df.select_dtypes("number").corr(). Never label-encode an unordered category to force it in — the resulting r depends on alphabetical order. |
18 |
| 9 | Diverging colormap without center=0 |
The neutral colour lands wherever the data mid-range happens to be. Correlations running −0.1 to +0.9 put white at +0.4, so a genuinely positive 0.2 renders in the "negative" blue range. The chart contradicts the numbers printed on it. | center=0 always, and for correlations vmin=-1, vmax=1 as well. Full recipe: annot=True, fmt=".2f", cmap="coolwarm", center=0, vmin=-1, vmax=1, square=True. |
18, 21 |
| 10 | Sequential palette on unordered categories | Three species coloured with viridis makes Gentoo the bright top of a scale and Adelie the dark bottom of one. Readers infer a ranking that does not exist, and the dark category is physically harder to see. The mirror error — a qualitative palette on income deciles — destroys an ordering that does exist. |
Unordered → qualitative (colorblind, deep, Set2). Low→high → sequential (viridis, rocket, crest). Meaningful midpoint → diverging with center=. Ask what kind of variable it is before every palette=. |
21 |
| 11 | swarmplot silently drops points on large data |
Past roughly 500–1000 rows per category the layout algorithm runs out of horizontal room and emits UserWarning: X% of the points cannot be placed. Those points are moved or hidden, so the chart no longer represents the data — and the warning scrolls past unread. |
Treat that warning as a build failure. Switch to stripplot(jitter=True, alpha=0.4), to a violin, or plot a random sample. Never ship a chart that produced it. |
17 |
| 12 | Raw counts compared across unequal groups | With 152 Adelie, 124 Gentoo and 68 Chinstrap, the Chinstrap curve is short everywhere for a reason that has nothing to do with flipper length. The reader concludes something about penguins from a fact about sampling. | stat="density" and common_norm=False — both parts. stat="density" alone still normalises across all groups combined. Keep common_bins=True and print the n values. |
16 |
| 13 | Alphabetical order for ordered categories | "Fri, Sat, Sun, Thur"; "High, Low, Medium"; "eight, five, four, nine, one". Object-dtype columns carry no order, so the axis reads as nonsense and any trend the reader sees is an artefact of the alphabet. | order=["Thur","Fri","Sat","Sun"] per call, or permanently with pd.Categorical(col, categories=..., ordered=True) so groupby, pivot and every plot inherit it. Note order= also filters out omitted levels. |
17 |
| 14 | multiple="layer" with three or more hue groups |
The default. Translucent bars overlap into colours that appear nowhere in the legend, and the visible height of the front group is not its count. | element="step", fill=False for outlines, multiple="dodge" for exact heights with few bins, or col= facets once you pass three groups. |
16 |
| 15 | pointplot lines imply a trend between unordered categories |
A line drawn from "Cherbourg" to "Queenstown" suggests something exists between them. Readers see a slope regardless of whether the x axis has an order. | Use pointplot only for genuinely ordered x (day, class, dose, age band). Otherwise linestyle="none" for bare dots with intervals — note this replaced join=False in v0.13. |
17 |
| 16 | Auto-scaled heatmaps compared side by side | Chart A spans −0.2 to 0.4 and chart B spans −0.9 to 0.95, yet both use the same darkest red and darkest blue. Placed together they look equally strong. | Pin vmin and vmax to the same values in every chart of the same quantity. If you use robust=True, say so in the caption — it hides the extremes. |
18 |
| 17 | KDE invents density past the data bounds | A Gaussian kernel has infinite support and cut=3 draws three bandwidths past the extreme observation, so a density curve for "tip amount" crosses zero into negative money. The estimator is also biased at the true boundary, understating how many values sit near it. |
cut=0 to stop at the data, clip=(0, None) to state a physical limit, or log_scale=True for positive skewed variables. Violins default to cut=2 and need the same treatment. |
16, 17 |
| 18 | pairplot drops non-numeric columns and NaN rows silently |
A 12-column DataFrame yielding a 5×5 grid means seven columns were non-numeric — sometimes genuinely categorical, sometimes numbers parsed as object because of a stray "N/A" or a thousands separator. Rows with NaN vanish from the affected cells. |
print(df.dtypes) and df.isna().sum() first; pd.to_numeric(..., errors="coerce") for columns that should be numeric. Constrain with vars= — panels grow as k², so 20 columns is 400 panels. |
20 |
| 19 | plt.title() on a figure-level plot |
plt.title() acts on the current axes, which after relplot/displot/catplot/lmplot is whichever facet was drawn last. Your figure title lands on one small bottom-right panel. Same for plt.xlabel, plt.xlim, plt.savefig. |
Go through the returned grid: g.figure.suptitle("...", y=1.02), g.set_axis_labels(), g.set_titles("{col_name}"), g.set(xlim=...), g.savefig(), then g.tight_layout(). Use g.figure, not the deprecated g.fig. |
15–20 |
| 20 | figsize ignored by figure-level functions |
plt.figure(figsize=(14, 6)) before a relplot does nothing: the function builds its own figure and orphans yours, which is why an empty extra figure often appears in the notebook. |
Size the facet: height= inches tall, width = height * aspect, total width ≈ columns × that. Exact inches after the fact: g.figure.set_size_inches(12, 5). |
20, 21 |
| 21 | lmplot (and every figure-level function) rejects ax= |
TypeError: lmplot() got an unexpected keyword argument 'ax'. Figure-level functions own their figure by design, so they cannot be inserted into a layout you built. Same for relplot, displot, catplot, jointplot, pairplot, clustermap. |
Use the axes-level twin: regplot, scatterplot, histplot, boxplot, heatmap — all take ax=. Decide which family you need before you write the layout, because they are not interchangeable at the last minute. |
19, 20 |
| 22 | plt.savefig() after plt.show() writes a blank file |
show() renders and then clears the current figure on most backends, so the following savefig() paints an empty canvas — with no error message at all. |
Save first, show second. Better: keep a handle (fig, ax = plt.subplots()) and call fig.savefig(...), which is bound to that figure. Canonical call: plt.savefig("f.png", dpi=300, bbox_inches="tight"). |
21 |
| 23 | Wide data where long was meant (and long where a matrix was meant) | Passing a wide DataFrame with no x/y makes seaborn plot every column against the index — convenient, and nonsense if you meant long form. In the other direction, sns.heatmap(data=flights, x="year", y="month") raises TypeError: matrix functions have no x, y or hue. |
Long form for everything except heatmap/clustermap: one row per observation, one column per variable. Reach matrix form with pivot (one value per cell), pivot_table(aggfunc=) (many), crosstab (two categoricals) or corr. df.melt() goes back. |
15, 18 |
| 24 | ci= replaced by errorbar= |
Deprecated in v0.12 and removed from the categorical and relational functions since. Old ci=95 is now errorbar=("ci", 95), ci="sd" is errorbar="sd", ci=None is errorbar=None. Any tutorial written before 2022 teaches the dead API. (regplot/lmplot are the exception — they still take ci=.) |
Use errorbar=, and say in the caption which one it is. Related renames: join=False→linestyle="none", scale=→density_norm= on violins, g.fig→g.figure, bare palette= now needs a matching hue= plus legend=False. |
15, 17 |
| 25 | distplot is deprecated |
Deprecated in 0.11; it still runs in 0.13 with a warning and will be removed. It combined histogram, KDE and rug in one function whose defaults changed the meaning of the y axis silently — switching the KDE on turned counts into density. | Axes-level: histplot, kdeplot, ecdfplot, rugplot. Figure-level: displot(kind=...). The literal translation of sns.distplot(x) is sns.histplot(x, kde=True, stat="density"). |
16 |
| 26 | sns.load_dataset() needs the internet |
It downloads a CSV from the mwaskom/seaborn-data repository on GitHub. In an exam hall, a locked-down lab or behind a proxy it raises URLError or times out, and a whole classroom stalls on cell one. |
Cache once while online (to_csv, then pd.read_csv), or clone the repo and pass data_home=. Restore the touches load_dataset applies — notably the ordered categorical for flights["month"]. Seaborn also caches under ~/seaborn-data after the first successful call. |
15–21 |
Every one of them is a function that computes something and then draws only the result. A bar is a mean; a line is a mean; a band is a bootstrap; a box is five quantiles; a violin and a KDE are smoothers with a tuning parameter; a correlation is a linear summary. None of them tell you what they threw away. That is the single idea Part II has been circling: a seaborn plot is a statistic wearing a picture, and you are responsible for knowing which statistic.
A pre-flight check
Four lines, run before any figure that matters. They catch rows 4, 8, 13, 18 and 23 outright, and put you in a position to notice 2 and 12.
print(df.shape, df.dtypes.value_counts().to_dict()) # shape and dtype mix print(df.isna().sum().loc[lambda s: s > 0]) # which columns lose rows print(df[cat_col].value_counts()) # group sizes and their order print(df[x_col].duplicated().any()) # will a lineplot aggregate?
23.2 Choosing the right plot
Students reach for a chart type and then look for a question it answers. Reverse that. The procedure below is four questions asked in a fixed order, and it terminates in a small set of candidates every time.
Step 1 — What is the question, in one sentence?
Write it down before opening the plotting library. The sentence determines everything that follows, and vague sentences produce vague charts.
| Question shape | Example | Family |
|---|---|---|
| How is this variable spread out? | "What do flipper lengths look like?" | Distribution (Ch 16) |
| Does this number differ across groups? | "Do smokers tip differently?" | Categorical (Ch 17) |
| How do two numbers move together? | "Does tip rise with the bill?" | Relational / regression (Ch 15, 19) |
| How many are there in each group? | "How many passengers per class?" | countplot (Ch 17) |
| How does this change over time? | "Are passenger numbers growing?" | lineplot (Ch 15) |
| Which of many variables relate to each other? | "What should I model?" | Matrix / grids (Ch 18, 20) |
| How do two categoricals interact? | "Which class embarked where?" | crosstab + heatmap (Ch 18) |
Step 2 — How many variables are in that sentence?
Count the variables you named, including any grouping variable. One variable is a distribution. Two is a comparison or a relationship. Three means one of them must go on a third channel — hue first, then style, then size, in that order of readability. Four or more means faceting: col=, then row=.
hue (≤ 6 levels), then style (≤ 4 levels), then col, then row. size is a last resort — humans judge area badly. Beyond five variables, draw two figures.Step 3 — What type is each one?
Numeric or categorical, and if categorical, ordered or not. This is the step that most often exposes a data problem rather than a plotting problem: a "numeric" column that is really an ID, a day-of-week column with no order attached, an integer pclass that should be a category. Fix the dtype before you fix the chart — pd.Categorical(..., ordered=True) once beats order= in ten places.
Step 4 — How big is n, per group?
Sample size decides how much detail a mark can honestly carry. Under about ten observations per group, do not summarise at all: quartiles computed from six numbers are noise dressed as statistics, and a violin fitted to six numbers is a curve invented out of nothing.
| Rows per group | Show | Do not show |
|---|---|---|
| < 10 | Every point: stripplot, swarmplot, or the raw numbers |
Box, violin, boxen, KDE, error bars |
| 10 – 50 | swarmplot, box with points overlaid, ECDF |
Boxen; KDE without a rug underneath |
| 50 – 1,000 | Box, violin (cut=0), stripplot with alpha, histogram, scatter |
swarmplot above ~500 — it starts dropping points |
| 1,000 – 100,000 | Box, violin, histogram, 2-D histogram, KDE contours, scatter with alpha |
Any per-point mark at full opacity |
| > 100,000 | boxenplot, hexbin, 2-D histogram, datashader |
Scatter, swarm, strip, bootstrap CIs (slow and pointless at that n) |
The decision table
Read the first column that matches your sentence.
| You have | You want to know | Use | Watch for |
|---|---|---|---|
| 1 numeric | Shape of the distribution | histplot; kdeplot for a smooth read; ecdfplot as referee |
Bin count (#5), bandwidth (#6), boundaries (#17) |
| 1 categorical | How many in each level | countplot (horizontal if labels are long) |
Category order (#13) |
| 2 numeric | Association, clusters, outliers | scatterplot; regplot if you want a trend line |
Overplotting (§23.4), band meaning (#3) |
| 2 numeric, x ordered/time | Trend over the ordering | lineplot |
Silent aggregation (#2); sort=True scrambles trajectories |
| 1 categorical + 1 numeric, small n | Every value | swarmplot, or stripplot with jitter |
Dropped points (#11) |
| 1 categorical + 1 numeric, medium n | Spread and skew across groups | boxplot; violinplot(cut=0) if shape matters |
Hidden bimodality (#7) |
| 1 categorical + 1 numeric | The average, and whether the difference is reliable | barplot (baseline 0) or pointplot (ordered x only) |
Mean-not-count (#1), unlabelled error bars (§23.3) |
| 1 categorical + 1 binary 0/1 | A rate | barplot on the 0/1 column — its mean is the proportion |
Label the axis "rate", not "survived" |
| 2 categorical | Association between them | crosstab + heatmap; or countplot with hue |
Counts vs normalize= answer different questions |
| 2 categorical + 1 numeric | A value per cell | pivot_table + heatmap |
fill_value=0 is a claim; blank ≠ zero |
| Many numeric | Which pairs relate | heatmap(df.corr(numeric_only=True)) to screen, then pairplot(vars=...) to look |
numeric_only (#8), center=0 (#9), k² panels (#18) |
| 2 numeric + their margins | Relationship and both distributions at once | jointplot; JointGrid if you need to layer |
kind="reg"/"hex" reject hue |
| Any of the above, split by a group | The same chart per subset | relplot/displot/catplot/lmplot with col=, row= |
Figure-level rules (#19, #20, #21) |
| 2 numeric, binary outcome | Probability of the outcome | lmplot(logistic=True, y_jitter=0.03) |
Needs statsmodels; slow; y must be numeric 0/1 |
Drawing a second chart costs ten seconds. If a histogram and an ECDF tell the same story, publish the one your audience reads faster. If they disagree, the disagreement is the finding — usually a bandwidth, a bin edge, or a spike of repeated values that the smoother has smeared into a plausible bump.
23.3 Honest charts
Every technique in this section is available to someone acting in good faith and to someone acting in bad faith, and the resulting figures are indistinguishable. That is why it belongs in a teaching guide rather than an ethics appendix: the defence is not virtue, it is a checklist.
1. Truncated axes exaggerate differences
Two bars, 102 and 105. On an axis from 0 to 110 they are the same height to the eye — correctly, because they differ by 3%. On an axis from 100 to 106 one is five times the other. Nothing has been falsified: the numbers are right, the tick labels are right, and the reader who does not check the axis walks away with a false ratio.
ax = sns.barplot(data=tips, x="day", y="total_bill", errorbar=None) ax.set_ylim(17, 22) # DISHONEST on bars: length no longer encodes value # if the differences are small and real, change the mark, not the axis sns.pointplot(data=tips, x="day", y="total_bill", errorbar=("ci", 95)) # position-encoded, so zooming the y axis is legitimate here
The exception is what makes the rule teachable. Forcing a time series of body temperature to start at 0 °C compresses every real fluctuation into a flat line at the top of the chart — that is a different lie, the lie of hiding a real effect. Ask which channel is doing the encoding: length demands zero, position does not.
You will not usually type set_ylim to deceive anyone. You will get truncation for free from facet_kws={"sharey": False}, where every panel rescales to fill its own box and groups with wildly different magnitudes come out looking identical. Tick labels are the only signal, and readers skip tick labels. If you unshare an axis, write "y-axis scaled independently per panel" in the title.
2. A hidden n behind an average
A bar at 4.8 built from 500 reviews and a bar at 4.9 built from 3 reviews are drawn identically. So are two heatmap cells whose proportions come from 200 rows and from 2. So are two facets, one fitted on 300 rows and one on 30 after NaN removal. Seaborn will never tell you, and normalisation — which is usually the statistically correct choice — removes the last visual trace of n.
# put n back where the reader can see it counts = penguins["species"].value_counts() labels = [f"{s} (n={counts[s]})" for s in counts.index] # or annotate each facet from its own subset def panel(data, **kws): ax = plt.gca() ax.scatter(data["total_bill"], data["tip"], s=18, alpha=0.6, **kws) 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(panel)
The rule is simple and unpopular: normalising is right, hiding the counts is not. Put n in the legend labels, in a per-panel annotation, or in the caption — but put it somewhere.
3. Unlabelled error bars
A vertical cap on a bar can be a 95% confidence interval, one standard error, one standard deviation, or a 50% percentile interval. These differ by a factor of three or more on the same data, and they answer different questions. A chart with unlabelled error bars is not readable in any scientific sense, and the reader's default assumption is usually wrong.
errorbar= |
Describes | Shrinks as n grows? | Reader should conclude |
|---|---|---|---|
("ci", 95) (seaborn default) |
Uncertainty in the estimated mean, bootstrapped | Yes | "We know where the average is, to about this precision" |
("se", 1) |
One standard error of the mean, computed analytically | Yes | Same reading, ~68% coverage, no bootstrap cost |
"sd" |
Spread of the observations themselves | No | "Individual values vary this much" |
("pi", 50) |
25th to 75th percentile of the raw values | No | Same as SD, but non-parametric |
None |
Nothing | — | Whatever they already believed |
The mistake this enables is specific and common: with 244 rows the CI caps are short, and a reader concludes "the data is tightly clustered". It is not. Only the estimate of the average is precise. If spread is what your audience cares about, a box or a violin is the right chart and error bars are the wrong one.
"Bars are group means; caps are bootstrap 95% confidence intervals (n = 152 / 124 / 68)." That sentence makes the figure interpretable, takes ten seconds to write, and is missing from most student work and a fair amount of published work.
4. Colour that implies an order that is not there
Covered as gotcha #10, restated here because it is a rhetorical failure as much as a technical one. A sequential ramp on unordered categories tells the reader that one group is the top of a scale. A diverging map without center= puts "neutral" on an arbitrary value, so small positive effects render as negative. A rainbow map manufactures boundaries — the eye reads the cyan-yellow transition as an edge in the data when the data changed smoothly through it.
The corresponding honest habits: match palette type to variable type; anchor diverging maps with center=0 and symmetric limits; never use jet; and pin vmin/vmax whenever two charts of the same quantity will be seen together.
5. Cherry-picked bins, bandwidths and windows
The bin count is a free parameter that changes what the reader concludes, and no caption convention requires you to disclose it. Shifting all bin edges by half a bin can merge or split a peak without changing a single value. A KDE with bw_adjust=2 can erase a mode; with bw_adjust=0.3 it can invent three.
The honesty checklist
- Do bars start at zero? Is a truncated axis on a position-encoded chart, and is it stated?
- Is n visible for every group, in the legend, an annotation or the caption?
- Is every error bar identified by type and level?
- Does the palette type match the variable type, and is a diverging map centred?
- Are bin width, bandwidth and any
robust=orvmin/vmaxclipping stated? - Do the facets share axes; if not, does the title say so?
- Does the title state what the chart shows, rather than which variables it contains?
- Would the conclusion survive a reader who redraws it with the opposite defensible choice?
23.4 When the data is too big to plot
A scatterplot of 200,000 points is a solid black rectangle in which perhaps 80% of the markers are hidden underneath the top layer. The chart is not merely ugly — it is wrong, because the visible density has no relationship to the actual density. The fixes form a ladder, and you should climb it in order rather than jumping to the bottom rung.
| Rung | Technique | Works up to | Cost |
|---|---|---|---|
| 1 | alpha=0.02–0.3 — density becomes darkness |
~50,000 | Individual outliers become invisible |
| 2 | Smaller markers: s=6, edgecolor=None |
~50,000 | Nothing, do this always at scale |
| 3 | Random subsample of 2,000–5,000 rows | Unlimited | Rare events may not be sampled; fix the seed |
| 4 | Hexbin: jointplot(kind="hex") |
~10 million | Individual points gone; hexagons tile without square-grid artefacts |
| 5 | 2-D histogram: histplot(x=, y=, bins=50, cbar=True) |
~10 million | Bin-size sensitivity returns (#5) |
| 6 | KDE contours: kdeplot(x=, y=, fill=True, levels=8) |
~1 million | Smoothing assumption; slow; needs enough points per contour |
| 7 | Aggregate first, plot the summary | Unlimited | You choose the aggregation, so state it |
| 8 | datashader / holoviews — rasterise before rendering |
Billions | Leaves seaborn entirely |
big = tips.sample(200_000, replace=True, random_state=0) # rungs 1+2: transparency and small markers sns.scatterplot(data=big, x="total_bill", y="tip", alpha=0.02, s=6, edgecolor=None) # rung 3: subsample - usually enough to see the shape sns.scatterplot(data=big.sample(3000, random_state=0), x="total_bill", y="tip", alpha=0.4) # rungs 4+5: bin instead of drawing points sns.jointplot(data=big, x="total_bill", y="tip", kind="hex", height=5) sns.histplot(data=big, x="total_bill", y="tip", bins=50, cbar=True) # rung 6: density contours sns.kdeplot(data=big, x="total_bill", y="tip", fill=True, levels=8, thresh=0.05) # rung 7: aggregate, then plot a small summary hourly = big.groupby("size", observed=True)["tip"].agg(["mean", "count"]).reset_index() sns.barplot(data=hourly, x="size", y="mean", errorbar=None)
Three related large-data problems
Discrete x. When x takes six values, points stack on six vertical lines and 5 points look like 500. This is not overplotting in the usual sense and alpha does not fix it: use stripplot(jitter=0.25), x_estimator=np.mean on a regplot, or histplot(discrete=True).
The bootstrap becomes the bottleneck. The default CI resamples 1,000 times per unique x value. On a series with 5,000 distinct timestamps that is five million resamples and the cell hangs. Use errorbar=None (usually correct on unique x anyway), errorbar="se" (analytic), or n_boot=200, seed=0. The same applies to robust= and logistic= fits, which refit the model on every replicate.
Vector files explode. An SVG or PDF of 100,000 marks stores 100,000 shapes and can reach tens of megabytes. Rasterise just the data layer and keep the text vector: ax.set_rasterized(True) then plt.savefig("f.pdf", dpi=300).
While you are still asking questions, a 5,000-row random sample answers almost all of them in a fraction of the time — shape, clusters, skew and outliers all survive sampling. Once you know what the figure needs to say, go back to the full data and draw a binned or aggregated version of exactly that claim.
23.5 Seaborn versus the alternatives
Knowing when not to reach for a tool is part of knowing the tool. Seaborn occupies a specific niche: statistical charts of tabular data, drawn once, from Python, for a static medium.
| seaborn | matplotlib | plotly | Excel / Power BI | |
|---|---|---|---|---|
| Built for | Statistical charts of tidy DataFrames | Any 2-D drawing, plus 3-D | Interactive web charts and dashboards | Business reporting by non-programmers |
| Lines of code for a faceted, coloured, CI-banded plot | 1 | 30–60 | 3–10 | Not really available |
| Does statistics for you | Yes — means, CIs, KDEs, regressions, clustering | No | Some (trendlines, marginals) | Trendlines, averages |
| Interactivity | None (static image) | Minimal | Native: hover, zoom, filter, animate | Native in Power BI |
| Fine control over every artist | Via the matplotlib objects it returns | Total | Good, different API | Limited |
| Reproducible from code | Yes | Yes | Yes | No — clicks are not code |
| Output for print / LaTeX | Excellent (PDF, SVG at 300+ dpi) | Excellent | Workable, needs a headless renderer | Poor |
| Handles 1M+ points | Only via binning | Only via binning | Poorly without WebGL modes | No |
| Audience can explore the data | No | No | Yes | Yes |
| Learning cost | Low, if you know pandas | High | Medium | Low |
The relationship with matplotlib is not a competition
Seaborn is matplotlib with a statistics layer and a semantic-mapping layer on top. sns.scatterplot() ends up calling ax.scatter(). Every axes-level function returns a real matplotlib.axes.Axes, and every figure-level function exposes g.figure, g.axes and g.axes_dict. So "seaborn cannot do X" is almost always false: retrieve the object and use the matplotlib API. Log scales, shaded spans, twin axes, custom tick formatters, insets, GridSpec mosaics — all available, all through matplotlib, all after the seaborn call.
When not to use seaborn
- Interactive dashboards. If the audience needs to hover, filter, drill down or change a date range, they need plotly / Dash, Bokeh, Streamlit or Power BI. A static PNG cannot answer a follow-up question.
- 3-D. Seaborn has no 3-D functions at all. Use
matplotlib'smplot3dor plotly — and first ask whether the 3-D chart is better as two 2-D ones, which it usually is. - Networks and graphs. Nodes and edges are not tabular in seaborn's sense. Use
networkxwith matplotlib, orgraphviz. - Maps and geospatial data. Use
geopandas,folium,plotlyorcartopy. Seaborn has no projections, no basemaps and no notion of coordinate reference systems. - Million-point scatter at full fidelity. Use
datashader/holoviews, which rasterise before rendering. Seaborn's answer is to bin, which is often the right answer — but it is a different chart. - Live-updating or streaming charts. Seaborn draws once. Use a dashboard framework.
- One number, or a table of eight numbers. No chart. Write the number, or format the table.
Matplotlib is a chef's knife: it will do anything, and nothing quickly. Seaborn is a mandoline — one class of job, done in one motion, with an opinion about how it should look. Plotly is a serving trolley: it gets the food to a table where people can help themselves. Excel is a microwave: perfectly adequate, universally available, and not what you use when the result has to be reproduced next year.
23.6 Question bank
Grouped by chapter. Work through them without the notes first; anything you cannot answer in two sentences points at a section to reread. The starred style — mechanism, then consequence, then fix — is what an interviewer is listening for.
Foundations (Chapter 14)
- What does seaborn add on top of matplotlib, given that it draws with matplotlib?
- What is long-form (tidy) data, and why do almost all seaborn functions require it?
- Which seaborn function is the exception to that rule, and what shape does it need instead?
- How does seaborn decide whether a column is categorical or numeric, and how do you override it?
- What do you lose by passing numpy arrays instead of a DataFrame with named columns?
- Name the four figure-level faceting functions and the axes-level families they wrap.
- What is a "semantic mapping" and which three channels does seaborn offer?
- Where does
sns.load_dataset()get its data, where does it cache, and what does it do to the raw CSV? - What does
sns.set_theme()change, and why does calling it after building a figure do nothing?
Relational plots (Chapter 15)
- State the difference between
scatterplotandlineplotin one sentence about aggregation. - You call
lineplotonflightsand get a shaded band. What is it, and how would you prove it? - Explain
errorbar=("ci", 95)versuserrorbar="sd"versuserrorbar=None. Which shrinks with n? - What is the difference between
size="size"ands=60? - Rank
hue,sizeandstyleby readability, and give a level cap for each. - Why does a continuous
hueproduce no colourbar, and how do you add one? - When would you set
sort=Falseon alineplot? - What does
relplotgive you thatscatterplotcannot, and what does it take away? - Your default CI makes a 5,000-timestamp line plot hang. Give three fixes in order of preference.
- What is redundant encoding and when is it worth the extra channel?
Distribution plots (Chapter 16)
- What replaced
distplot, and what was wrong with it? - How do you choose a bin count? Why is
binwidthusually the better argument? - What do the five
stat=values mean, and what does a bar height of 0.06 mean under"density"? - Explain the boundary problem in a KDE and give three fixes.
- What does
bw_adjustdo, and what mistake does the default enable? - Why is the ECDF the referee when a histogram and a KDE disagree?
- You compare three groups with 5,000, 500 and 50 rows. What exactly do you set, and why are both parts required?
- Name the four
multiple=layouts and say when each one fails. - What does a rug plot add underneath a KDE?
- What is the difference between
histplotanddisplot, in terms of what each returns?
Categorical plots (Chapter 17)
- What does the height of a bar in
sns.barplot()represent? What aboutcountplot? - Where does seaborn's confidence interval come from, and why is it slightly different each run?
- Explain every element of a boxplot, including why the whiskers stop where they do.
- Why 1.5 × IQR, and what happens to that convention on skewed data?
- When would you choose a violin over a box, and what does
cut=0fix? - How do you plot a rate rather than a count, using a 0/1 column?
stripplotorswarmplot— how do you decide, and what warning ends the argument?- Why must a bar chart start at zero while a pointplot need not?
- Your days come out "Fri, Sat, Sun, Thur". Give three fixes and say which is most durable.
- What is
boxenplotfor, and at what n does it start paying off?
Matrix plots (Chapter 18)
- Why can a tidy DataFrame not be passed straight to
sns.heatmap()? - Give the four routes to matrix form and the situation each one is for.
pivotversuspivot_table: what is the error that tells you to switch?- Recite the six arguments of the correlation-heatmap recipe.
- A correlation of 0 — are the variables unrelated? Justify with an example.
- When do you use Spearman instead of Pearson, and what does comparing the two matrices diagnose?
- Why mask the upper triangle, and how many distinct facts does a 10-variable matrix contain?
- You see r = 0.98 between two features. Is that good news?
- What does
clustermapdo thatheatmapdoes not, and what is its main trap? - Why does a constant column produce a blank stripe across a correlation heatmap?
- Counts or proportions: what different questions do
normalize="index"andnormalize="columns"answer?
Regression plots (Chapter 19)
- What exactly is the shaded region around a regression line, and name two things it is not?
- Why is a narrow band not evidence of a good model?
- Can you get a slope, intercept or R² out of
regplot? If not, where do they come from? - What is the Anscombe quartet and what does it prove?
- Describe a healthy residual plot, then two unhealthy patterns and their fixes.
- Which model arguments require statsmodels, and why are they slow?
- How do you plot a regression when the outcome is binary, and what must the y column look like?
- What are
x_estimatorandx_binsfor, and does either change the fit? - Define Simpson's paradox and give the two-line check for it in seaborn.
- Why does
truncate=Truedefault to on?
Grids (Chapter 20)
- Give the practical one-question test for whether a function is axes-level or figure-level.
- Why are small multiples easier to read than five colours in one panel? Answer in terms of perception.
- What is the difference between
g.map()andg.map_dataframe()? - Name three situations that force you to build a
FacetGridby hand. - Where does
sharey=Falsego on acatplot, and what is the risk in each direction? - Why is the diagonal of a pairplot a distribution, and what does
corner=Truesave? sns.pairplot(df)on a 12-column frame gives a 5×5 grid. What happened?- What do
map_upper,map_lowerandmap_diagmake possible thatpairplotdoes not? - When do you need
JointGridinstead ofjointplot? - How do you reach one specific panel of a returned grid, and why is
axes_dictsafer than indexing? - Why is
col_wrapincompatible withrow=?
Styling and output (Chapter 21)
- What is the difference between style and context, and does either change the canvas size?
- Which context do you use for a projected lecture slide, and what is its scale factor?
- Give the rule that maps the three palette types onto three kinds of variable.
- Why is
jeta poor colormap? Give three independent reasons. - How do you size a
relplot, and why isplt.figure(figsize=...)ignored? - Your saved PNG is blank. Diagnose it.
- What does
bbox_inches="tight"do, and how is it different fromtight_layout()? - List four things that make a figure robust for a colour-blind reader, and name the one test that catches all of them.
- When do you use PNG and when SVG or PDF? What is the exception for dense scatters?
- Seaborn cannot do what you need — a log axis, a shaded band, a mixed 2×2 panel. What do you do?
- Why is
sns.move_legend()preferable to a secondplt.legend()?
Cross-cutting (the ones that get asked)
- Explain the axes-level / figure-level split and list its six practical consequences.
- Name every seaborn function that silently aggregates, and say what each computes.
- Name every seaborn function with a smoothing parameter, and name the parameter.
- Which seaborn defaults are statistically opinionated rather than neutral, and what is each one's opinion?
- Walk through choosing a chart for a question you have not seen before.
- Given a figure someone else made, what do you check before believing it?
23.7 Summary of Part II
A student who has finished Chapters 14 to 22 can now do the following without help:
- Take an unfamiliar DataFrame, check its shape, dtypes and missingness, and produce a defensible first look — a correlation heatmap to screen, a corner pairplot on the interesting variables, and a distribution of every column that matters.
- Choose a chart from the question rather than from habit, using variable count, variable type and sample size per group.
- Say precisely what every mark on their chart represents: which statistic, computed over which rows, with which rows dropped.
- Move fluently between the two families — axes-level functions with
ax=for composed layouts, figure-level functions withcol=/row=for small multiples — and reach the underlying matplotlib objects when seaborn stops. - Encode extra variables in the right order (
hue, thenstyle, then facets), with a palette whose structure matches the variable's structure, readable in greyscale and under red-green deficiency. - Diagnose a relationship rather than merely draw one: check the residuals, check for a confounder with
hue=, check the shape against Anscombe's warning, and go to statsmodels for the numbers. - Reshape long data into matrix form with
pivot,pivot_table,crosstaborcorr, and read a correlation matrix without over-reading it. - Produce a figure that survives its medium: correct context for the viewing distance, human axis labels with units, the conclusion annotated on the chart, saved before
show()at the right dpi withbbox_inches="tight". - Look at someone else's chart and find the truncated axis, the hidden n, the unlabelled error bar and the palette that invented an ordering.
Three sentences worth memorising
Axes, accept ax= and compose; figure-level functions own a Figure, facet with col=/row=, size with height/aspect, and are reached through the grid object they return — which is why plt.title() lands on the wrong panel.center=0 for a real midpoint; and whatever you normalise away — sample size above all — put back in the caption.Next: Chapter 24 puts everything together in four end-to-end projects.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.