Visualisation in Python with Seaborn: 10 Code-Along Examples

Learn seaborn by plotting. Ten copy-and-run examples covering axes-level against figure-level functions, shared parameters, scatter encodings, count and bar plots, error bars, box plots, aggregating line plots, and facet grids.

Learn seaborn by plotting. Ten copy-and-run examples covering axes-level against figure-level functions, the shared parameters, scatter encodings, count and bar plots, error bars, box plots, aggregating line plots, point plots, and facet grids.

The seaborn article covers the library that does the work matplotlib leaves to you: computing group means, drawing confidence intervals, splitting data into colour-coded subgroups, and laying out grids of related charts. The thirty-line matplotlib chart becomes three lines, and the price of that leverage is that seaborn makes statistical decisions on your behalf, which you should be able to see and override. Every figure here was produced by the code above it, and several examples print the numbers seaborn computed so you can check what the picture is claiming. This workbook works through the whole range: the two function families, the parameters that transfer between every chart, scatter encodings, counts against means, error bars, box plots and category order, line plots that aggregate for you, point plots, and faceting. The idea that ties it together arrives in the last example.

Every example uses this dataset, which you can paste once:

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
 
rng = np.random.default_rng(23)
n = 480
 
segment = rng.choice(["Enterprise", "SMB"], n, p=[0.4, 0.6])
band = rng.choice(["under 5", "5 to 10", "over 10"], n)
budget = rng.gamma(4, 2400, n).round(0)
lift = {"Enterprise": 1.35, "SMB": 1.0}
roi = (1.8 + 0.00004 * budget + rng.normal(0, 0.35, n)) * np.array([lift[s] for s in segment])
hours_map = {"under 5": 62, "5 to 10": 71, "over 10": 79}
 
campaigns = pd.DataFrame({
    "region":  rng.choice(["North", "South", "East", "West"], n),
    "segment": segment,
    "channel": rng.choice(["Organic", "Paid", "Email", "Social"], n, p=[.35, .3, .2, .15]),
    "loyalty": rng.choice(["yes", "no"], n, p=[0.45, 0.55]),
    "training_band": band,
    "budget": budget,
    "roi": roi.round(2),
    "team_size": rng.integers(2, 16, n),
    "satisfaction": np.clip(rng.normal(7.2, 1.1, n) + (segment == "Enterprise") * 0.5, 1, 10).round(1),
    "performance": np.array([rng.normal(hours_map[b], 9) for b in band]).round(1),
})

1. Three lines and a chart

Seaborn takes a DataFrame and column names rather than arrays, which is why the whole chart fits on one line.

sns.set_theme(style="whitegrid")
sns.relplot(data=campaigns, x="budget", y="roi", kind="scatter", height=3.2, aspect=1.7)
plt.show()

One call, and seaborn has picked the axis labels, the grid and the styling

Notice everything you did not write. The axis labels came from the column names, the grid and background came from set_theme, and the point size and colour came from seaborn’s defaults, which are chosen to be legible rather than to be exciting. That is the trade the library makes: you describe the data and the question, and it decides the presentation. plt.show() at the end is still matplotlib’s job, and forgetting it is the single most common reason a seaborn script runs cleanly and produces nothing. Note also that seaborn wants long-form data, one row per observation with variables in columns, which is the shape most databases and CSVs already give you.

2. Axes-level against figure-level

Seaborn has two families of function and the difference decides what you can do next.

# axes-level: draws into an axes you created
fig, ax = plt.subplots(figsize=(5.6, 3))
sns.scatterplot(data=campaigns, x="budget", y="roi", ax=ax)

Axes-level functions accept ax= and slot into a matplotlib layout you control

# figure-level: owns the whole figure, and can split it
sns.relplot(data=campaigns, x="budget", y="roi", kind="scatter",
            col="segment", height=3, aspect=1.1)

Figure-level functions take col= and row=, which axes-level functions cannot

The same data, and one capability separating them. Axes-level functions such as scatterplot, lineplot, countplot and boxplot draw into a single axes, accept ax=, and are what you want when seaborn is one panel inside a matplotlib figure you are arranging yourself. Figure-level functions such as relplot, catplot and displot own the entire figure, return a FacetGrid rather than an axes, and support col= and row= for splitting the data into a grid. The practical rule is to default to figure-level while exploring, because the moment you want the chart split by a category you would otherwise have to rewrite it, and to switch to axes-level when the chart has to live inside a layout you already control.

3. The parameters that transfer everywhere

Almost every seaborn call takes the same arguments, so learning them once pays off across every chart type.

sns.relplot(
    data      = campaigns,
    x         = "budget",
    y         = "roi",
    kind      = "scatter",
    hue       = "region",
    hue_order = ["North", "South", "East", "West"],
    col       = "segment",
    col_order = ["Enterprise", "SMB"],
    palette   = "viridis",
    height    = 3,
    aspect    = 1.1,
)

hue, col and the _order arguments that stop alphabetical sorting deciding for you

Seven arguments describing a four-dimensional view: budget, ROI, region in colour and segment across panels. The _order variants are the ones people discover too late. Without them, seaborn sorts categories alphabetically, which is correct for names and wrong for anything with a natural sequence, so a size band reads “5 to 10, over 10, under 5” and a set of quarters reads “Q1, Q2, Q3, Q4” only by luck. Setting the order explicitly also pins the colours, which matters when the same categories appear in several charts in one report and you want the Enterprise line to be the same colour every time. height and aspect size each panel rather than the whole figure, so a two-column grid is twice as wide as a one-column grid at the same settings.

4. Encoding more variables in a scatter

Beyond x and y, three arguments let a single scatter carry more information, and there is a limit worth respecting.

sns.relplot(data=campaigns.sample(160, random_state=3), x="budget", y="roi",
            kind="scatter", hue="segment", size="team_size", style="loyalty",
            sizes=(15, 160), height=3.4, aspect=1.6, alpha=0.8)

hue, size and style encoding three more variables on top of x and y

Five variables on one chart: two positional, one in colour, one in area and one in marker shape. It works here and it is close to the ceiling, because each extra encoding costs the reader a trip to the legend, and the legend for this chart is already taller than the plot. Two practical notes. sizes=(15, 160) sets the range that the size variable maps into, which is worth doing because seaborn’s default range is conservative and the differences can be too subtle to read. And alphamatters as soon as points overlap: without it a dense region is a solid blob, and with it you can see where the mass of the data actually sits, which is the honest picture. Sampling the frame here is deliberate, since 480 points at these sizes would overplot regardless.

5. Counting rows against averaging a column

Two chart types look similar and answer completely different questions.

fig, ax = plt.subplots(1, 2, figsize=(9, 3))
sns.countplot(data=campaigns, x="channel", ax=ax[0], color="#1e2331")
sns.barplot(data=campaigns, x="channel", y="satisfaction", ax=ax[1], color="#3f88c5")
plt.show()

Left: how many rows per channel. Right: mean satisfaction per channel

channel counts          mean satisfaction
Organic    178          Email      7.44
Paid       153          Organic    7.40
Email       86          Paid       7.55
Social      63          Social     7.48

The left chart has no y because there is nothing to average: seaborn counts the rows itself, which makes countplot the chart version of value_counts(). The right chart has a y and therefore computes the mean of that column within each category. Reading them together is the lesson. Organic has by far the most campaigns and almost the lowest satisfaction, while Social has the fewest campaigns and a middling score, so the tall bar on the left and the tall bar on the right mean entirely different things and neither one implies the other. When category names are long, swapping x= for y=flips the bars horizontal and saves you rotating labels.

6. What the error bar is actually showing

Seaborn draws an uncertainty interval by default, and knowing which one it is changes how you read the chart.

fig, ax = plt.subplots(1, 3, figsize=(10, 3))
sns.barplot(data=campaigns, x="segment", y="roi", ax=ax[0], color="#1e2331")
sns.barplot(data=campaigns, x="segment", y="roi", ax=ax[1], errorbar="sd", color="#1e2331")
sns.barplot(data=campaigns, x="segment", y="roi", ax=ax[2], errorbar=None,
            estimator="median", color="#1e2331")
plt.show()

The same comparison with a 95% confidence interval, a standard deviation, and neither

             mean  median    std
segment
Enterprise  2.959    2.93  0.542
SMB         2.187    2.17  0.405

Three charts, three different claims. The default whisker is a bootstrapped 95 percent confidence interval, which describes how precisely you know the *mean* and shrinks as the sample grows, so on 480 rows it is barely visible and a reader may conclude the groups have almost no spread. errorbar=”sd” shows the standard deviation instead, which describes the spread of the *data* and does not shrink with sample size, and the difference between the two panels is stark even though the underlying numbers are identical. The third panel switches the statistic entirely with estimator=”median”, which is what you want when a skewed distribution pulls the mean away from the typical value. Note that seaborn 0.12 replaced the old ci= argument with errorbar=; ci= still works in 0.13 but is deprecated.

7. Box plots and the order problem

A box plot shows a whole distribution per category, which makes the category order the thing most likely to ruin it.

fig, ax = plt.subplots(1, 2, figsize=(9, 3))
sns.boxplot(data=campaigns, x="training_band", y="performance", ax=ax[0], color="#c9ced8")
sns.boxplot(data=campaigns, x="training_band", y="performance", ax=ax[1],
            order=["under 5", "5 to 10", "over 10"], showfliers=False,
            whis=[0, 100], color="#3f88c5")
plt.show()

Left: alphabetical order hides a clean trend. Right: the same data in a sensible order

The left panel is what you get for free and it is close to useless: alphabetical sorting puts “5 to 10” first, “over 10” second and “under 5” last, so a monotonic relationship between training and performance appears as a zigzag. The right panel is the same data with order set, and the trend is immediately obvious. Two more arguments earn their place here. showfliers=False drops the individual outlier dots when they distract from a group comparison, though you should have looked at them first. And whis=[0, 100] stretches the whiskers to the true minimum and maximum instead of stopping at 1.5 interquartile ranges, which turns the chart into a full-range summary and makes the outlier dots redundant by definition.

8. Line plots that aggregate for you

When several rows share an x value, seaborn averages them and draws the uncertainty around that average without being asked.

fig, ax = plt.subplots(1, 2, figsize=(9.5, 3))
sns.lineplot(data=returns, x="year", y="return_rate", ax=ax[0])
sns.lineplot(data=returns, x="year", y="return_rate", ax=ax[1], errorbar="sd", hue="segment")
plt.show()

One line from 24 rows per year, with the band showing uncertainty

year
2019    0.1453
2020    0.1300
2021    0.1190
2022    0.1058
2023    0.0971
2024    0.0904
2025    0.0906
2026    0.0734

There are 24 rows per year in this data and one point per year on the chart, because seaborn grouped by x, took the mean, and shaded the interval around it. That automatic aggregation is the main reason to use lineplot over matplotlib’s plot for repeated-measurement data, and it is also something to be aware of: if you expected 192 points and see eight, nothing is broken. The shaded band follows the same rule as Example 6, defaulting to a 95 percent confidence interval and switching to spread with errorbar=”sd”. Adding hue splits the aggregation by group, so the right panel shows two independent means and two bands rather than one line coloured differently.

9. Point plots, and colours that mean something

Point plots show the same statistics as bar plots in a lighter form, and one argument stops them implying a trend that does not exist.

colour_map = {"Enterprise": "#1e2331", "SMB": "#3f88c5"}
 
fig, ax = plt.subplots(1, 2, figsize=(9.5, 3))
sns.pointplot(data=campaigns, x="region", y="roi", ax=ax[0],
              linestyle="none", capsize=0.2, color="#1e2331")
sns.countplot(data=campaigns, x="channel", hue="segment", ax=ax[1], palette=colour_map)
plt.show()

Left: point plot with the connecting line removed. Right: an explicit colour dictionary

Point plots strip a bar chart down to the estimate and its interval, which makes them better than bars when you are comparing several categories and the bar rectangles add nothing but ink. The detail that matters is the connecting line: by default seaborn joins the points, which reads as a trend and is actively misleading when the categories are unordered, as four regions are, so linestyle=”none” removes it. In older seaborn this argument was join=False, which is deprecated in 0.13. On the right, palette takes a dictionary instead of a palette name, which is how you pin a specific colour to a specific category so that Enterprise is the same navy in every chart in a report, regardless of which categories happen to appear in each one.

10. Facet grids, and the model

col and row split one chart into a grid of small multiples, which is the fastest way to check whether a pattern holds everywhere.

sns.set_theme(style="ticks", context="notebook")
 
g = sns.relplot(data=campaigns, x="budget", y="roi", kind="scatter",
                col="segment", row="loyalty", hue="region",
                height=2.5, aspect=1.3, palette="mako", alpha=0.85)
 
g.set_axis_labels("Campaign budget (GBP)", "Return on investment")
g.set_titles("{row_name} loyalty | {col_name}")
g.figure.suptitle("ROI against budget, split four ways", y=1.02)
plt.show()

Four panels sharing axes, so every comparison between them is fair

Four panels from one call, and the thing that makes them trustworthy is that a FacetGrid shares axis limits across the grid by default, so the Enterprise panels genuinely sit higher than the SMB ones rather than being auto-scaled into looking the same. The methods afterwards are how you finish a figure-level chart, since it has no single ax to call set_xlabel on: set_axis_labels writes both axis labels across the grid, set_titles takes a template with {row_name} and {col_name}placeholders, and g.figure reaches the underlying matplotlib figure whenever you need something seaborn does not wrap.

The model that ties the ten together is that a seaborn call is a sentence with four slots, and every chart in this workbook is the same sentence with different words. The data slot is a long-form DataFrame, one row per observation. The variable slots, x, y, hue, size, style, col and row, map columns onto visual channels, and the only real design question is how many channels a reader can decode at once, which Example 4 suggests is about five. The kind slot picks the statistical transform, and this is where seaborn is doing work matplotlib would not: count tallies rows, bar and point take a mean per group, line takes a mean per x value, box computes five quantiles, and scatter transforms nothing at all. And the estimator slot, estimator and errorbar, decides which summary and which uncertainty, which is the part that silently makes a claim on your behalf if you never set it. Learn the four slots and every chart type is the same call, which is the actual reason three lines can replace thirty.

Work through these and you have the article in practice: the one-line chart and the plt.show() that makes it appear; axes-level against figure-level and the col= that separates them; the shared parameters and the _order arguments that stop alphabetical sorting; five encodings on one scatter and the point where it becomes too many; counts against means; the default confidence interval and what errorbar=”sd” says instead; box plots ruined and rescued by category order; line plots that aggregate 24 rows into one point; point plots without the false trend line, and colours pinned by dictionary; and facet grids with shared scales. The habit that follows is a sceptical one: whenever seaborn draws a line, a bar or a band you did not compute, work out which rows it aggregated and which statistic it chose, because that is where a chart makes a claim you never checked.

Thanks for reading, Andrei.

View Comments (1)

Leave a Reply

Subscribe to My Newsletter

Subscribe to my email newsletter to get the latest posts delivered right to your email. Pure inspiration, zero spam.