Learn to turn default charts into finished ones. Ten copy-and-run examples covering point highlighting, hue-based emphasis, text and arrow annotations, KDE comparisons, rug plots, swarm and violin plots, palette choice, and bootstrap uncertainty.
The customisation and annotations article covers the gap between a chart that shows your data and one that communicates something. Default output gives every point the same weight and leaves the reader to do all the interpretive work, and closing that gap is a small toolkit: colour one thing and grey the rest, write the finding on the chart, choose a palette that matches the kind of variable you are showing, and represent uncertainty honestly rather than hiding it. Every figure here was produced by the code above it, and the last example resamples the data 2,000 times so the confidence interval you see is one this workbook actually computed. The idea that ties it together arrives in that last example.
Every example uses this air-quality dataset, which you can paste once:
import matplotlib.pyplot as pltimport numpy as npimport pandas as pdimport seaborn as sns sns.set_theme(style="whitegrid")rng = np.random.default_rng(41) cities = ["Long Beach", "Vandenberg", "Fresno", "Bakersfield", "Sacramento"]rows = []for city in cities: k = {"Long Beach": 210, "Vandenberg": 150, "Fresno": 190, "Bakersfield": 180, "Sacramento": 170}[city] o3_mu = {"Long Beach": 0.028, "Vandenberg": 0.041, "Fresno": 0.046, "Bakersfield": 0.044, "Sacramento": 0.035}[city] for _ in range(k): month = int(rng.integers(1, 13)) rows.append({ "city": city, "month": month, "day": int(rng.integers(1, 29)), "year": int(rng.choice([2012, 2013, 2014])), "O3": max(0.001, rng.normal(o3_mu + 0.004 * np.sin(month / 2), 0.010)), "NO2": max(0.5, rng.normal(22 if city == "Long Beach" else 13, 6)), "CO": max(0.05, rng.normal(0.9 if city == "Long Beach" else 0.5, 0.25)), }) air = pd.DataFrame(rows).round({"O3": 4, "NO2": 1, "CO": 3})
1. Colour one point, grey the rest
The most direct way to direct a reader’s eye is to build a list of colours the same length as the data.
target = air.sort_values("NO2", ascending=False).iloc[0] point_colors = [ "#e2571e" if (d == target.day) and (c == target.city) and (y == target.year) else "#c9ced8" for d, c, y in zip(air.day, air.city, air.year)] fig, ax = plt.subplots(figsize=(6, 3.4))sns.regplot(data=air, x="NO2", y="O3", fit_reg=False, ax=ax, scatter_kws={"facecolors": point_colors, "alpha": 0.75})plt.show()

One observation in orange against 899 in grey
Nine hundred points and your eye goes to exactly one. The mechanism is a list comprehension walking zip of the columns that define the row you care about, producing one colour per row in the same order as the DataFrame, which is the requirement that makes this work and the thing that breaks silently if you sort the frame afterwards. scatter_kws is seaborn’s pass-through: everything in that dictionary goes straight to the underlying matplotlib scatter call, which is how seaborn exposes low-level options without wrapping each one. fit_reg=False strips the regression line that regplot would otherwise add, leaving only the dots.
2. The same idea through hue
When the rule is data-derived rather than a hard-coded coordinate, adding a column and using hue is cleaner and gives you a legend for free.
long_beach = air[air.city == "Long Beach"].copy()peak = long_beach.O3.max()long_beach["point_label"] = ["Highest O3 day" if v == peak else "Other days" for v in long_beach.O3] fig, ax = plt.subplots(figsize=(6, 3.4))sns.scatterplot(data=long_beach, x="NO2", y="O3", hue="point_label", palette={"Highest O3 day": "#e2571e", "Other days": "#c9ced8"}, ax=ax)plt.show()

A derived label column drives both the colour and the legend
The .copy() is not decoration. Filtering a DataFrame can hand you a view onto the original, and assigning a new column to a view raises SettingWithCopyWarning and may not do what you asked, so any time you filter and then modify, copy first. Passing palette as a dictionary rather than a name pins each label to a specific colour, which matters because seaborn would otherwise assign them alphabetically and your highlight colour could land on the wrong group. The choice between this and Example 1 comes down to control: use the colour list when you want full manual control, and hue when you want the legend written for you.
3. Highlighting a whole group, and using alpha to show density
The same pattern scales from one point to an entire category, and transparency does a second job while you are there.
city_colors = ["#e2571e" if c == "Long Beach" else "#c9ced8" for c in air["city"]] fig, ax = plt.subplots(figsize=(6, 3.4))sns.regplot(data=air, x="CO", y="O3", fit_reg=False, ax=ax, scatter_kws={"facecolors": city_colors, "alpha": 0.35})ax.text(1.45, 0.072, "Long Beach runs high on CO", color="#e2571e", fontsize=9)plt.show()

Transparency turns overlapping grey points into a density map
alpha=0.35 is doing real analytical work here rather than making the chart prettier. With hundreds of overlapping points at full opacity you see an outline and nothing about what is inside it; with transparency, darker patches are places where many observations stack, so the grey cloud becomes a density map while the highlighted group still reads clearly because it is fully saturated against a washed-out background. ax.text places a label at data coordinates, so the numbers you pass are in the same units as the axes, which means the label moves correctly if you change the axis limits and lands in the wrong place if you change the data.
4. Arrows for crowded regions
When the point you want to label sits in the middle of everything else, the label has to move and an arrow has to connect it back.
jan = air[air.month == 1]lb = jan[jan.city == "Long Beach"].sort_values("CO", ascending=False).iloc[0] fig, ax = plt.subplots(figsize=(6, 3.4))sns.scatterplot(data=jan, x="CO", y="NO2", color="#c9ced8", ax=ax, legend=False)ax.scatter([lb.CO], [lb.NO2], color="#e2571e", zorder=5)ax.annotate("Highest January CO reading", xy=(lb.CO, lb.NO2), xytext=(0.35, 36), arrowprops={"facecolor": "#6f7580", "width": 2, "shrink": 0.04}, backgroundcolor="white", fontsize=9)plt.show()

xy is the arrow tip, xytext is where the text can breathe
Two coordinate pairs with two jobs: xy is the arrow tip and belongs on the data point, xytext is where the text sits and belongs wherever there is room. Two smaller arguments matter more than they look. shrink=0.04 pulls the arrowhead back a few percent so it points at the dot rather than covering it, and backgroundcolor=”white” puts an opaque rectangle behind the text so it stays readable when it lands on top of other points, which is the difference between a label and a smudge. The zorder=5 on the highlighted point forces it to draw above the grey cloud, since matplotlib otherwise paints in call order and your highlight can end up underneath.
5. Labels instead of a legend
With fifteen or twenty categories a legend becomes a wall of text that the reader has to cross-reference. Putting the name where the point would be removes the lookup entirely.
by_city_month = (air[air.month.isin([3, 7])] .groupby(["city", "month"], as_index=False)["O3"].mean())by_city_month["month_name"] = by_city_month["month"].map({3: "March", 7: "July"}) fig, ax = plt.subplots(figsize=(6, 3.4))sns.scatterplot(data=by_city_month, x="month_name", y="O3", s=0, ax=ax)for _, row in by_city_month.iterrows(): ax.annotate(row["city"], (row["month_name"], row["O3"]), ha="center", size=9)ax.set_xlim(-0.6, 1.6)plt.show()

s=0 hides the dots and the text becomes the data point
s=0 draws the points at zero size, so the scatter call is doing nothing but establishing the axes and their scales, and the loop then places each label at its own coordinates. The text is the mark. This is the right pattern whenever the category name is the information, in a slope chart between two periods like this one, or a scatter of countries, or any comparison where the reader’s question is “which one is that” rather than “how many are there”. Calling annotate on the axes rather than through plt matters in multi-panel figures, since plt targets whichever axes is currently active and ax names the one you mean.
6. Comparing distributions with KDEs
Histograms are honest for one distribution and awkward for two, because bin width and offset change the picture. A density curve sidesteps both.
fig, ax = plt.subplots(figsize=(6, 3.2))sns.kdeplot(data=air[air.year == 2012], x="O3", fill=True, label="2012", ax=ax, color="#1e2331")sns.kdeplot(data=air[air.year != 2012], x="O3", fill=True, label="Other years", ax=ax, color="#e2571e")ax.legend()plt.show()

Two filled densities, with the overlap visible where they agree
Two curves on one axes, and the overlapping region is visibly darker because two transparent fills are stacking, which tells you at a glance where the distributions agree and where they part company. The argument is fill=True in current seaborn; the older shade=True is deprecated, and sns.distplot, which many tutorials still use for this, was deprecated in 0.11 and removed, replaced by histplot, kdeplot and displot. The thing to keep in mind about any KDE is that it is a model rather than a measurement: it smooths, it extends slightly beyond your real minimum and maximum, and with a small sample it will happily draw a confident-looking curve from very little, which is what the next example is for.
7. The rug plot as an honesty check
A density curve looks equally authoritative whether it rests on 900 observations or 18. A rug shows which one you have.
sub = air[air.city == "Vandenberg"]small = sub.sample(18, random_state=1) fig, ax = plt.subplots(1, 2, figsize=(9, 3))sns.kdeplot(data=sub, x="O3", fill=True, ax=ax[0], color="#3f88c5")sns.kdeplot(data=small, x="O3", fill=True, ax=ax[1], color="#3f88c5")sns.rugplot(data=small, x="O3", ax=ax[1], color="#1e2331", height=0.08)plt.show()

Left: 150 observations. Right: 18, with the rug showing exactly where they sit
The two curves are similarly smooth and similarly convincing, and one of them is built from 18 points. The tick marks along the bottom of the right-hand panel are the correction: they show exactly where real observations sit, so a reader can see the gaps that the smoothing has filled in and judge the curve accordingly. This costs one line and belongs on any density plot where the sample size is not obvious, particularly in a faceted chart where some panels have far fewer rows than others. If the rug looks sparse, that is not a problem with the chart, it is information the chart was hiding.
8. Showing every observation
When you want individual points rather than a summary, three chart types trade off differently as the data grows.
mar = air[air.month == 3] fig, ax = plt.subplots(1, 3, figsize=(11, 3.2))sns.swarmplot(data=mar, y="city", x="O3", size=3, ax=ax[0], color="#1e2331")sns.stripplot(data=air, y="city", x="O3", size=2, jitter=True, alpha=0.4, ax=ax[1], color="#1e2331")sns.violinplot(data=air, y="city", x="O3", ax=ax[2], color="#c9ced8")plt.show()

Swarm on 97 rows, strip and violin on all 900
A swarm plot places every point at its true value and nudges it perpendicular until nothing overlaps, so the width of the swarm *is* the density and no observation is hidden. That guarantee is also its limit, because the nudging has nowhere to go once a category has more than a few hundred points, and seaborn will warn you that points cannot be placed. The middle panel shows the fallback, stripplot with jitter and transparency, which scales to the full dataset at the cost of overlapping dots. The violin on the right gives up individual points entirely in exchange for a clean shape, and it is the same KDE from Example 6 mirrored around its axis, which means the same caution about small samples applies.
9. Matching the palette to the variable
Colour palettes come in three kinds, and using the wrong kind is a common way to make a chart say something untrue.
monthly = air.groupby("month", as_index=False)["O3"].mean()monthly["vs_mean"] = monthly["O3"] - monthly["O3"].mean() fig, ax = plt.subplots(1, 3, figsize=(11, 3))sns.barplot(data=air, x="city", y="O3", ax=ax[0], hue="city", palette="deep", legend=False, errorbar=None)sns.barplot(data=monthly, x="month", y="O3", ax=ax[1], hue="month", palette="rocket", legend=False)sns.barplot(data=monthly, x="month", y="vs_mean", ax=ax[2], hue="vs_mean", palette="vlag", legend=False)plt.show()

Qualitative, sequential and diverging palettes on the same data
Three palettes for three kinds of variable. Qualitative palettes such as deep use distinct hues of similar intensity and are for unordered categories, where any implied ranking would be an accident of the colour scheme. Sequential palettes such as rocket run light to dark and are for quantities with a natural low-to-high order, where the darkness itself carries the value. Diverging palettes such as vlag have a neutral middle and two directions, and they are specifically for data with a meaningful midpoint, a change against a baseline, a difference from an average, a positive-or-negative. Using a sequential palette on that third chart would imply the most negative month is simply “least”, when what it actually is is furthest below the average in one direction. Note the hue= with legend=False pattern, which is how current seaborn wants a palette applied to a single variable.
10. Showing uncertainty honestly, and the model
A bar chart of means is a claim about the world. The interval around it is what tells the reader how much to believe.
vals = air[air.city == "Fresno"]["O3"].to_numpy()boot_means = np.array([rng.choice(vals, len(vals), replace=True).mean() for _ in range(2000)])lo, hi = np.percentile(boot_means, [2.5, 97.5])print(f"Fresno mean {vals.mean():.5f} bootstrap 95% CI [{lo:.5f}, {hi:.5f}] n={len(vals)}") fig, ax = plt.subplots(1, 2, figsize=(9.5, 3.2))sns.barplot(data=air, x="city", y="O3", ax=ax[0], errorbar=("ci", 95), hue="city", palette="deep", legend=False)sns.histplot(boot_means, bins=40, ax=ax[1], color="#3f88c5")ax[1].axvline(lo, color="#e2571e"); ax[1].axvline(hi, color="#e2571e")plt.show()
Fresno mean 0.04569 bootstrap 95% CI [0.04431, 0.04732] n=190

Left: seaborn’s confidence intervals. Right: the resampling that produces them
The right-hand panel is what the little black whisker on the left is made of. Bootstrapping resamples the 190 Fresno observations with replacement 2,000 times, takes the mean of each resample, and reads the 2.5th and 97.5th percentiles off the resulting distribution, which is exactly what seaborn does internally when it draws a confidence interval. Seeing it drawn out makes the interpretation concrete: the interval describes how much the *mean* would move if you collected the data again, not how spread out the readings are. Two cities whose intervals overlap heavily are two cities you cannot confidently rank, and that is the sentence a bare bar chart never lets a reader say.
Everything in this workbook composes into one finished chart:
colors = ["#e2571e" if c == "Fresno" else "#c9ced8" for c in air["city"]]peak_row = air[air.city == "Fresno"].sort_values("O3", ascending=False).iloc[0] fig, ax = plt.subplots(figsize=(7, 3.6))sns.regplot(data=air, x="NO2", y="O3", fit_reg=False, ax=ax, scatter_kws={"facecolors": colors, "alpha": 0.4, "s": 18})ax.annotate("Fresno peak", xy=(peak_row.NO2, peak_row.O3), xytext=(28, 0.030), arrowprops={"arrowstyle": "->", "color": "#6f7580"}, backgroundcolor="white", fontsize=9)ax.set_xlabel("NO2 (ppb)")ax.set_ylabel("O3 (ppm)")ax.set_title("Fresno against every other monitoring site")sns.despine()plt.show()

One group emphasised, one point annotated, everything else deliberately quiet
The model that ties the ten together is that customisation is a budget, and the budget is the reader’s attention. Every chart starts with all of it spent equally, which is another way of saying none of it is spent usefully, and each technique here moves some of it deliberately: colour spends attention on one point or one group, annotation spends it on a specific moment and states the finding in words so the reader does not have to infer it, and transparency, greying and despine are how you take attention back from things that do not deserve it. The statistical layer works the same way in reverse, because a KDE, a violin and a bar of means all spend the reader’s trust rather than their attention, and the rug plot, the swarm and the bootstrap interval are how you give an honest account of how much trust the picture has earned. A finished chart is one where both budgets have been spent on purpose.
Work through these and you have the article in practice: a colour list built by comprehension and the row order it depends on; hue on a derived column, with the .copy() that keeps it safe; alpha turning overplotting into a density map; arrows with a white background so labels survive a crowded region; labels replacing a legend; two KDEs and the deprecated arguments to avoid; a rug plot exposing an 18-point curve; swarm, strip and violin as the data grows; qualitative against sequential against diverging palettes; and 2,000 bootstrap resamples showing what a confidence interval is made of. The habit that follows is a one-sentence test: before you save a chart, write down the single sentence you want the reader to leave with, and if nothing on the chart points at that sentence, you have drawn the data but not the finding.
Thanks for reading, Andrei.
[…] Data Visualisation in Python, Customisation and Annotations: 10 Code-Along Examples […]
[…] Data Visualisation in Python, Customisation and Annotations: 10 Code-Along Examples […]