All 57 lessons

Lesson 39 of 57 · Data Visualization

Seaborn: Statistical Plots in a Few Lines

By Skillancy Editorial Team

Run the code in the free Python Compiler or on your own computer. Lessons that read files use the FreshBasket practice data: download the dataset (ZIP).

Matplotlib gives you total control, but control costs lines of code. To draw a box plot of delivery time per city you must group the data yourself, loop, set labels, and colour boxes by hand. Seaborn is a layer on top of Matplotlib that understands DataFrames. You say which columns go on which axis, and it does the grouping, aggregating, colouring and legends for you. A chart that took fifteen lines in Matplotlib takes one in seaborn, and statistical extras such as confidence intervals and regression lines come built in.

Seaborn expects tidy (long) data: one row per observation, one column per variable, exactly the shape the previous pandas lessons taught you to produce with melt and groupby(...).reset_index(). You name the columns with x=, y=, hue= (colour) and col= (small multiples) and seaborn does the rest. Because seaborn returns ordinary Matplotlib axes, everything from the Matplotlib lessons (titles, formatters, annotations, saving) still applies.

Worked examples

Example 1: The seaborn model

Question: Build the tidy table, then draw the first chart.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import skillancy_style

PALETTE = skillancy_style.PALETTE
sns.set_palette(PALETTE)

orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
stores = pd.read_csv("stores.csv")
customers = pd.read_csv("customers.csv")
df = (
    orders.merge(stores[["store_id", "city"]], on="store_id")
    .merge(customers[["customer_id", "is_prime", "acquisition_channel"]], on="customer_id")
)
delivered = df[df["order_status"] == "Delivered"].copy()

fig, ax = plt.subplots()
sns.histplot(data=delivered, x="delivery_minutes", binwidth=2, ax=ax)
ax.set_title("Delivery time distribution")
ax.set_xlabel("Minutes")

Seaborn histogram of delivered order delivery minutes, a bell shape centered near 24 minutes

How it works: sns.histplot(data=df, x="column") is the whole recipe: pass the DataFrame and name the column. Seaborn picks sensible defaults and returns a Matplotlib Axes, so you can pass ax= to draw on an existing axes and then use normal Matplotlib calls such as ax.set_title.

Reading the result: The histogram summarises 5,391 delivered orders with a mean of 23.7 minutes. Compare how short this is to the Matplotlib version in the last lesson.

Example 2: Distribution by group (hue)

Question: Do Prime customers get faster deliveries?

fig, ax = plt.subplots()
sns.histplot(data=delivered, x="delivery_minutes", hue="is_prime", binwidth=2, stat="density", common_norm=False, element="step", ax=ax)
ax.set_title("Prime and non-Prime customers see the same delivery times")
ax.set_xlabel("Delivery time (minutes)")

Overlaid step histograms of delivery minutes for Prime and non-Prime customers, nearly identical

How it works: hue="is_prime" splits the data and colours each group automatically, adding a legend. stat="density" with common_norm=False scales each group to the same total area, so groups of different sizes can be compared by shape. element="step" draws outlines so overlapping groups stay readable.

Reading the result: Prime customers average 23.6 minutes against 23.7 for others, a difference of 0.13 minutes. The curves overlap almost completely.

Example 3: Density and cumulative plots

Question: What share of deliveries finish within a given time?

fig, axes = plt.subplots(1, 2, figsize=(11, 4))
sns.kdeplot(data=delivered, x="delivery_minutes", hue="delivery_slot", common_norm=False, fill=True, alpha=0.2, ax=axes[0])
axes[0].set_title("Density by delivery slot")
axes[0].set_xlabel("Minutes")

sns.ecdfplot(data=delivered, x="delivery_minutes", ax=axes[1], color="#028090")
axes[1].axhline(0.9, color="#E76F51", linestyle="--")
axes[1].axvline(delivered["delivery_minutes"].quantile(0.9), color="#E76F51", linestyle="--")
axes[1].set_title("Cumulative share of deliveries")
axes[1].set_xlabel("Minutes")
axes[1].set_ylabel("Share delivered within")
fig.tight_layout()

Left, overlapping density curves by delivery slot; right, an empirical cumulative distribution with a line at the 90th percentile

How it works: kdeplot draws a smoothed density curve (a "kernel density estimate"), good for comparing shapes. ecdfplot draws the empirical cumulative distribution: at each time on the x-axis, the height is the share of deliveries that finished by then. It needs no binning, and it answers service-level questions directly, such as "what time covers 90% of orders?".

Reading the result: The dashed lines show that 90% of deliveries arrive within 32 minutes. The four slot curves are almost the same.

Example 4: Bar plots with confidence intervals

Question: Average delivery time by city, with uncertainty.

order = delivered.groupby("city")["delivery_minutes"].mean().sort_values().index

fig, ax = plt.subplots()
sns.barplot(data=delivered, y="city", x="delivery_minutes", order=order, color="#028090", errorbar=("ci", 95), ax=ax)
ax.set_xlabel("Average delivery time (minutes)")
ax.set_ylabel("")
ax.set_title("Average delivery time is about 24 minutes in every city")

Horizontal bar chart of average delivery minutes per city with 95 percent confidence interval whiskers, all very close together

How it works: Unlike Matplotlib's bar, sns.barplot aggregates for you: the bar is the mean of the rows in each category, and the whisker is a 95% confidence interval estimated by bootstrapping. Overlapping whiskers tell you that differences between cities are within the noise. Use estimator="median" or "sum" to change the summary, and order= to control the sequence.

Reading the result: The slowest city, Ahmedabad, averages 24.0 minutes and the fastest, Chennai, 23.1. With overlapping intervals, ranking them would be reading noise.

Example 5: countplot and proportions

Question: How many orders per payment method, split by status?

pay_order = df["payment_method"].value_counts().index

fig, ax = plt.subplots()
sns.countplot(data=df, x="payment_method", hue="order_status", order=pay_order, ax=ax)
ax.set_xlabel("")
ax.set_ylabel("Orders")
ax.set_title("Most orders are delivered, whatever the payment method")
ax.legend(title="Status", frameon=False)

Grouped bar chart counting orders per payment method split into delivered, cancelled and returned, with delivered dominating

How it works: countplot counts rows for each category without needing a pre-computed table. Adding hue makes grouped bars. Because the bars are counts, differences in total volume dominate; to compare rates (cancellation percentage), compute shares first and plot those with barplot.

Reading the result: The most common payment method is UPI with 3,330 orders. Cancellation rates are 6.0% to 6.7% across methods.

Example 6: Box and violin plots

Question: Compare the spread of delivery time across acquisition channels.

fig, axes = plt.subplots(1, 2, figsize=(12, 4), sharey=True)
chan_order = sorted(delivered["acquisition_channel"].unique())
sns.boxplot(data=delivered, x="acquisition_channel", y="delivery_minutes", order=chan_order, color="#028090", ax=axes[0], fliersize=2)
axes[0].set_title("Box plot")
sns.violinplot(data=delivered, x="acquisition_channel", y="delivery_minutes", order=chan_order, color="#F4A261", inner="quartile", ax=axes[1], cut=0)
axes[1].set_title("Violin plot")
for ax in axes:
    ax.set_xlabel("")
    ax.tick_params(axis="x", rotation=25)
axes[0].set_ylabel("Delivery time (minutes)")
fig.tight_layout()

A box plot and a violin plot of delivery minutes across customer acquisition channels showing nearly identical distributions

How it works: A violin plot is a box plot's richer cousin: its width shows the density at each value (a mirrored KDE), with quartile lines inside. Violins reveal shapes (two peaks, skew) that a box hides, but they are harder for a general audience to read. cut=0 stops the density from extending past the data range.

Reading the result: There are 5 acquisition channels, and their median delivery times differ by at most 0.0 minutes. How a customer was acquired does not change their delivery experience.

Example 7: Line plots with aggregation

Question: Show the trend of delivery time over months with an uncertainty band.

delivered["month"] = delivered["order_date"].dt.to_period("M").dt.to_timestamp()

fig, ax = plt.subplots()
sns.lineplot(data=delivered, x="month", y="delivery_minutes", errorbar=("ci", 95), marker="o", ax=ax)
ax.set_ylim(15, 35)
ax.set_xlabel("")
ax.set_ylabel("Average delivery time (minutes)")
ax.set_title("Delivery time held steady as volume grew tenfold")
fig.autofmt_xdate()

Line chart of average monthly delivery minutes with a shaded confidence band staying near 24 minutes

How it works: lineplot aggregates several rows per x value (mean by default) and draws a shaded 95% confidence band. In early months with few orders the band is wide, and it narrows as the sample grows, which is an honest picture of uncertainty. Passing raw rows instead of a pre-aggregated table lets seaborn do all the work.

Reading the result: The monthly average ranges from 22.4 to 25.3 minutes. Early months have wide bands because they have only 55 orders.

Example 8: Regression plots

Question: Is there a relationship between a customer's order count and their average basket value?

items = pd.read_csv("order_items.csv")
order_value = items.assign(v=items["quantity"] * items["unit_price"]).groupby("order_id")["v"].sum().rename("value")
cust = delivered.merge(order_value, on="order_id").groupby("customer_id").agg(orders=("order_id", "count"), avg_value=("value", "mean")).reset_index()

fig, ax = plt.subplots()
sns.regplot(data=cust, x="orders", y="avg_value", scatter_kws=dict(alpha=0.35, s=22), line_kws=dict(color="#E76F51"), ax=ax)
ax.set_xlabel("Delivered orders per customer")
ax.set_ylabel("Average basket value (₹)")
ax.set_title("Frequent customers do not spend more per basket")

Scatter plot of customers' order count against average basket value with a near-flat regression line and confidence band

How it works: regplot draws a scatter plot plus a fitted regression line with its confidence band. The slope tells you how much y changes per unit of x; a band that comfortably includes a flat line means the relationship may not be real. For several groups, lmplot adds hue and col.

Reading the result: The correlation across 584 customers is 0.00. Be careful: a correlation near zero is evidence of no linear relationship, not proof of no relationship at all.

Example 9: Correlation heatmap

Question: Which numeric columns move together?

num = delivered.merge(order_value, on="order_id")[["delivery_minutes", "discount_pct", "value"]]
num["units"] = delivered.merge(items.groupby("order_id")["quantity"].sum().rename("units"), on="order_id")["units"].values
corr = num.corr()

fig, ax = plt.subplots(figsize=(5.5, 4.5))
sns.heatmap(corr, annot=True, fmt=".2f", cmap="RdBu_r", vmin=-1, vmax=1, center=0, square=True, linewidths=1, ax=ax)
ax.set_title("Correlation between order measures")
ax.grid(False)

Correlation heatmap with red and blue colours showing weak correlation between delivery minutes, discount, basket value and units, with strong correlation only between value and units

How it works: DataFrame.corr() gives the matrix of pairwise Pearson correlations, and sns.heatmap colours it. For correlations use a diverging palette centred at zero (center=0, vmin=-1, vmax=1) so positive and negative values get opposite colours. Annotating each cell with the number makes it a table you can read.

Reading the result: The strongest relationship is between basket value and units (0.72), which is expected since more items cost more. Delivery time correlates only 0.02 with value.

Example 10: Facets and pair plots

Question: Make the same chart for every group at once, and view all pairs of variables.

g = sns.FacetGrid(delivered, col="city", col_wrap=4, height=2.1, aspect=1.15, sharey=True)
g.map_dataframe(sns.histplot, x="delivery_minutes", binwidth=3, color="#028090")
g.set_titles("{col_name}")
g.set_axis_labels("Minutes", "Orders")
g.figure.suptitle("Delivery time distribution by city", y=1.03, fontweight="bold")

Grid of eight small histograms of delivery minutes, one for each city, all with similar bell shapes

How it works: FacetGrid (and the figure-level functions sns.displot, catplot, relplot with col=) draws one panel per category and keeps the axes aligned, an easy way to build small multiples. pairplot(df) draws a scatter plot for every pair of numeric columns, with distributions on the diagonal, a quick way to explore a new dataset.

Reading the result: The grid has 8 panels with matching axes, and every city has the same bell shape centred near 24 minutes.

Common mistakes

1. Giving seaborn wide data instead of tidy data.

2. Forgetting ax= when mixing seaborn with a Matplotlib subplot grid, so the chart appears elsewhere.

3. Reading overlapping confidence intervals as differences.

4. Using a sequential palette for correlations (use a diverging one centred at zero).

5. Misspelling a column name.

sns.histplot(data=delivered, x="delivery_time")

Output

Traceback (most recent call last):
  File "example.py", line 1, in <module>
    sns.histplot(data=delivered, x="delivery_time")
    ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "example.py", line 1379, in histplot
    p = _DistributionPlotter(
        data=data,
        variables=dict(x=x, y=y, hue=hue, weights=weights),
    )
  File "example.py", line 110, in __init__
    super().__init__(data=data, variables=variables)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "example.py", line 634, in __init__
    self.assign_variables(data, variables)
    ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
  File "example.py", line 679, in assign_variables
    plot_data = PlotData(data, variables)
  File "example.py", line 58, in __init__
    frame, names, ids = self._assign_variables(data, variables)
                        ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
  File "example.py", line 232, in _assign_variables
    raise ValueError(err)
ValueError: Could not interpret value `delivery_time` for `x`. An entry with this name does not appear in `data`.

Practice questions

Practice 1 (Easy): Count plot of slots

Draw a count plot of orders per delivery slot, in time order.

Show solution
slot_order = ["Morning (6-10)", "Midday (10-2)", "Evening (4-8)", "Night (8-11)"]
fig, ax = plt.subplots()
sns.countplot(data=df, x="delivery_slot", order=slot_order, color="#028090", ax=ax)
ax.set_xlabel("")
ax.set_ylabel("Orders")
ax.set_title("Evening is the busiest slot")

Bar chart counting orders per delivery slot with evening the tallest

order= fixes the sequence. Evening has 2,220 orders.

Practice 2 (Medium): Violin by weekday

Compare the delivery-time distribution by weekday (Monday to Sunday) with a violin plot.

Show solution
days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
delivered["weekday"] = delivered["order_date"].dt.day_name()
fig, ax = plt.subplots(figsize=(9, 4))
sns.violinplot(data=delivered, x="weekday", y="delivery_minutes", order=days, color="#2A9D8F", inner="quartile", cut=0, ax=ax)
ax.set_xlabel("")
ax.set_ylabel("Delivery time (minutes)")
ax.set_title("Delivery time does not depend on the weekday")

Violin plots of delivery minutes for each weekday with similar shapes

All seven violins share the same shape, with medians between 23 and 24 minutes.

Practice 3 (Hard): Share of cancelled orders with intervals

Plot the cancellation rate per city as a bar chart with confidence intervals, sorted highest first.

Show solution
df["cancelled"] = (df["order_status"] == "Cancelled").astype(int)
rate_order = df.groupby("city")["cancelled"].mean().sort_values(ascending=False).index

fig, ax = plt.subplots()
sns.barplot(data=df, y="city", x="cancelled", order=rate_order, color="#E76F51", errorbar=("ci", 95), ax=ax)
ax.xaxis.set_major_formatter(lambda v, pos: f"{v:.0%}")
ax.set_xlabel("Cancellation rate")
ax.set_ylabel("")
ax.set_title("Cancellation rates differ little between cities")

Horizontal bar chart of cancellation rate per city with confidence intervals overlapping, sorted from highest to lowest

A 0/1 column averages to a rate, which barplot turns into a bar with an interval. The highest rate is 7.1% in Kolkata.

Key takeaways

  • Seaborn works from tidy DataFrames: name columns with x, y, hue and col.
  • histplot, kdeplot and ecdfplot show distributions; use hue to compare groups.
  • barplot aggregates and draws confidence intervals; countplot counts rows.
  • boxplot and violinplot compare spread across categories.
  • regplot and heatmap explore relationships; use a diverging palette for correlations.
  • Facets create small multiples; pairplot explores many variables at once.
  • Seaborn returns Matplotlib axes, so all your Matplotlib skills still apply.

Frequently asked questions

Seaborn or Matplotlib: which should I use?

Both. Use seaborn for fast statistical charts on DataFrames, and Matplotlib for fine control, annotations and layout. They work together because seaborn draws on Matplotlib axes.

What is the difference between axes-level and figure-level functions?

Axes-level functions (histplot, boxplot, scatterplot) draw on one ax. Figure-level functions (displot, catplot, relplot) create their own figure with facets and are controlled with col=, row=, height=.

How do I set a consistent theme?

sns.set_theme(style="whitegrid", palette=...) at the top of your notebook, or import the Skillancy style module as in these lessons.

What does the confidence interval mean?

It is a range that, if you repeated the sampling many times, would contain the true average about 95% of the time. Overlapping intervals suggest the difference may be due to chance.

Why does my seaborn chart look different on each run?

Bootstrapped confidence intervals use random resampling. Set seed=0 in the errorbar argument, or use errorbar=None, for repeatable pictures.

Can seaborn make interactive charts?

No, they are static images. For hover and zoom, use Plotly or Altair.

Newsletter

Keep learning Python

One email a week with Python and analytics guides, new practice questions and cohort dates. No spam, unsubscribe anytime.

Ready to practise? Try the Python interview questions