PythonMastery
intermediate 22 min read · lesson 3 of 9 in Data Science & ML

Visualisation with Matplotlib (and a Touch of Seaborn)

1 · The lesson

read

A number is an answer. A chart is an argument. The point of plotting isn't decoration — it's compressing a thousand rows into a shape your visual cortex can read in half a second. Done well, a chart kills hours of meeting debate. Done badly, it actively misleads.

You've seen the basics in ds-visualization. This lesson goes deeper into the figure/axes model that every serious matplotlib user works with, the six chart types worth knowing well, multi-panel layouts, styling for publication, and where seaborn earns its keep. We'll close with the rules for not lying to your readers — accidentally or otherwise.


1. The Figure/Axes Mental Model

Matplotlib has two APIs. The pyplot state machine — plt.plot(), plt.title(), plt.show() — is what tutorials use because it's three lines. The object-oriented API — fig, ax = plt.subplots() — is what real code uses because it scales.

python
import matplotlib.pyplot as plt
import numpy as np

# The state-machine way — works, but you're operating on "the current axes", whatever that is
plt.plot([1, 2, 3], [1, 4, 9])
plt.title("squares")
plt.show()

# The OO way — explicit handles
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot([1, 2, 3], [1, 4, 9])
ax.set_title("squares")
ax.set_xlabel("x")
ax.set_ylabel("y")
plt.show()
# Output: a single-panel line chart, 6x4 inches, with axis labels.

The objects:

  • Figure — the whole image (the canvas). One per file you save.
  • Axes — a single plot panel inside the figure. A figure can hold many. Don't confuse with axis (singular — the x-axis or y-axis of an Axes).
  • Axis — the x or y line of an Axes, with ticks, labels, and a scale.

Every method on the OO API targets a specific Axes. ax.set_title(...), ax.plot(...), ax.legend(...). That explicitness pays back the moment you need two panels, or want to embed a chart in a function, or have the chart styled by a script.

Rule for any chart that survives past today: fig, ax = plt.subplots() first, then operate on ax. No bare plt.something() after that — it's a future ambiguity bomb.


2. The Six Chart Types Worth Knowing

Most analytic questions resolve to one of six. Pick by what you're trying to show, not by what looks pretty.

ChartQuestion it answersExample
LineHow does Y change over a continuous X?Stock price over time
BarHow do Y values compare across discrete categories?Revenue by region
HistogramWhat's the distribution of Y?Shape of customer ages
ScatterDoes Y depend on X? (relationship, outliers)Height vs weight
BoxQuartiles, spread, and outliers per groupSalary by department
HeatmapPattern in a 2-D matrix of valuesCorrelation matrix, confusion matrix
python
import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(0)

# 1. Line — trends over a continuous x
x = np.linspace(0, 10, 100)
fig, ax = plt.subplots()
ax.plot(x, np.sin(x), label="sin")
ax.plot(x, np.cos(x), label="cos", linestyle="--")
ax.legend()
ax.set_title("Trig over [0, 10]")
plt.show()
# Output: two curves on one axis — sin solid, cos dashed, legend at top-right by default.

# 2. Bar — compare categories
categories = ["EU", "US", "APAC", "LATAM"]
revenue = [120, 250, 180, 90]
fig, ax = plt.subplots()
ax.bar(categories, revenue)
ax.set_ylabel("Revenue (M$)")
ax.set_title("Q1 Revenue by Region")
plt.show()
# Output: four vertical bars, EU/US/APAC/LATAM left to right.

# 3. Histogram — distribution shape
data = rng.standard_normal(1000)
fig, ax = plt.subplots()
ax.hist(data, bins=30, edgecolor="black")
ax.set_xlabel("value")
ax.set_ylabel("frequency")
ax.set_title("N(0, 1) sample")
plt.show()
# Output: bell-shaped histogram with 30 bins, centred near 0.

# 4. Scatter — relationship between two continuous variables
x = rng.standard_normal(200)
y = 2 * x + rng.standard_normal(200)
fig, ax = plt.subplots()
ax.scatter(x, y, alpha=0.5, s=20)
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_title("y vs x (with noise)")
plt.show()
# Output: cloud of points along a positive diagonal, alpha=0.5 makes overlap visible.

# 5. Boxplot — quartiles and outliers, grouped
groups = [rng.standard_normal(100), rng.standard_normal(100) + 1, rng.standard_normal(100) - 0.5]
fig, ax = plt.subplots()
ax.boxplot(groups)
ax.set_xticklabels(["A", "B", "C"])      # works on every matplotlib version
ax.set_ylabel("value")
ax.set_title("Distribution per group")
plt.show()
# Output: three boxes side-by-side; each shows median line, IQR box, whiskers, individual outlier dots.

# 6. Heatmap — 2-D matrix
matrix = rng.standard_normal((10, 10))
fig, ax = plt.subplots()
im = ax.imshow(matrix, cmap="viridis")
fig.colorbar(im, ax=ax)
ax.set_title("Random 10x10")
plt.show()
# Output: 10x10 coloured grid; viridis cmap goes dark-purple (low) to yellow (high); colourbar on the right.

Don't reach for pie charts. They're nearly always worse than a bar chart for the same data — humans read length better than angle.


3. Styling — Title, Labels, Legend, Ticks, Limits

Every chart that leaves your machine needs at minimum: a title, axis labels with units, and a legend if there's more than one series.

python
import matplotlib.pyplot as plt
import numpy as np

x = np.arange(2020, 2026)
us = [100, 120, 135, 150, 165, 180]
eu = [80, 90, 105, 115, 130, 140]

fig, ax = plt.subplots(figsize=(7, 4))

ax.plot(x, us, marker="o", label="US", color="#1f77b4", linewidth=2)
ax.plot(x, eu, marker="s", label="EU", color="#ff7f0e", linewidth=2)

ax.set_title("Annual Revenue by Region", fontsize=14, fontweight="bold")
ax.set_xlabel("Year")
ax.set_ylabel("Revenue (M USD)")
ax.set_ylim(0, 200)                          # avoid auto-scaling exaggeration
ax.set_xticks(x)                             # exactly the years we plotted
ax.grid(True, axis="y", linestyle="--", alpha=0.4)
ax.legend(loc="upper left", frameon=False)

# Annotate a specific event
ax.annotate("Acquisition", xy=(2023, 150), xytext=(2021.5, 175),
            arrowprops=dict(arrowstyle="->", color="grey"))

plt.show()
# Output: a two-line chart with US (blue, circles) and EU (orange, squares), 
# bold title, gridded y-axis, "Acquisition" callout pointing at the 2023 US data point.

Tick density matters more than people think. Auto-ticks on a year axis often pick 2020.5, 2021.5 — meaningless. Set them explicitly with ax.set_xticks(...). The same applies to log scales (ax.set_yscale("log")), percent formatters, and date formatters.


4. Multiple Subplots

plt.subplots(rows, cols) returns one Figure and an Axes array. Iterate with axes.flat for clean code regardless of grid shape.

python
import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(0)

fig, axes = plt.subplots(2, 3, figsize=(12, 6))
titles = ["A", "B", "C", "D", "E", "F"]

for ax, title in zip(axes.flat, titles):
    ax.hist(rng.standard_normal(1000), bins=20)
    ax.set_title(title)
    ax.set_xlim(-4, 4)                       # shared scale for comparison

fig.suptitle("Six samples from N(0, 1)", fontsize=14)
fig.tight_layout()
plt.show()
# Output: a 2x3 grid of histograms, each titled A-F, all sharing the same x-axis range,
# with a figure-level title above the grid.

fig.tight_layout() prevents labels from colliding. sharex=True, sharey=True in plt.subplots(...) gives every panel the same axis limits — essential when comparing distributions.

For irregular layouts (one large panel, two small ones), use gridspec:

python
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec

fig = plt.figure(figsize=(10, 6))
gs = GridSpec(2, 2, figure=fig)
big = fig.add_subplot(gs[:, 0])              # left column, both rows
top = fig.add_subplot(gs[0, 1])              # top-right
bot = fig.add_subplot(gs[1, 1])              # bottom-right
# Output: irregular 3-panel layout — one tall left panel, two small stacked right panels.

5. Twin Axes — Two Y-Scales on One Plot

When two series share an x-axis but have wildly different scales (revenue in millions vs. conversion rate in percent), use a twin axis.

python
import matplotlib.pyplot as plt
import numpy as np

months = np.arange(1, 13)
revenue = np.array([100, 110, 105, 130, 140, 160, 170, 165, 180, 190, 200, 220])
conversion = np.array([2.1, 2.3, 2.0, 2.4, 2.5, 2.7, 2.8, 2.6, 2.9, 3.0, 3.1, 3.2])

fig, ax1 = plt.subplots(figsize=(8, 4))

ax1.bar(months, revenue, color="#a8d0e6", label="Revenue")
ax1.set_xlabel("Month")
ax1.set_ylabel("Revenue (M USD)", color="#1f4e79")
ax1.tick_params(axis="y", labelcolor="#1f4e79")

ax2 = ax1.twinx()                            # share x, separate y
ax2.plot(months, conversion, color="#c0392b", marker="o", linewidth=2, label="Conversion %")
ax2.set_ylabel("Conversion (%)", color="#c0392b")
ax2.tick_params(axis="y", labelcolor="#c0392b")

fig.suptitle("Revenue vs. Conversion — 2025")
plt.show()
# Output: blue bars (revenue) against the left y-axis, red line (conversion) against the right y-axis,
# sharing the month x-axis; coloured tick labels match each series.

Twin axes are easy to misuse. Use them when the two series genuinely correlate and showing both shapes helps the story. Don't use them to cram two unrelated charts into one panel — that's chart-junk.


6. Annotations and Reference Lines

python
import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 10, 100)
y = np.sin(x)

fig, ax = plt.subplots()
ax.plot(x, y)

ax.axhline(0, color="grey", linestyle="--", linewidth=0.8)    # horizontal reference
ax.axvline(np.pi, color="red", linestyle=":", linewidth=1)    # vertical at x=π

# Plain text at a coordinate
ax.text(np.pi, 0.5, "x = π", ha="center", color="red")

# Annotation with arrow
ax.annotate("max", xy=(np.pi / 2, 1), xytext=(4, 1.1),
            arrowprops=dict(arrowstyle="->"))

ax.set_title("sin(x) on [0, 10]")
plt.show()
# Output: sine wave; dashed horizontal at y=0; dotted red vertical at x=π with label;
# arrow pointing to the maximum at (π/2, 1).

axhline / axvline for thresholds (target lines, breakeven points, regulatory limits). annotate for callouts that need an arrow; text for free-floating labels. Both accept coordinates in data units by default — what you'd expect.


7. Saving for Publication

python
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(7, 4))
ax.plot([1, 2, 3], [4, 5, 6])
ax.set_title("Demo")

fig.savefig("out.png", dpi=300, bbox_inches="tight")
fig.savefig("out.pdf", bbox_inches="tight")        # vector format for slides/papers
fig.savefig("out.svg", bbox_inches="tight")        # vector for web

Two settings that matter:

  • dpi=300 — print quality. The default (100) looks fine on a laptop screen and pixelates on projector or paper.
  • bbox_inches="tight" — crops away the figure's margin whitespace. Without it, you get a chunky border around the actual plot. Always pass it for production charts.

For papers, prefer PDF or SVG — they're vector formats, so they stay crisp at any zoom. For web, PNG at 2× the display resolution; SVG if the chart is simple enough that the markup stays small.


8. Style Sheets

Matplotlib's default look is dated. One line fixes it for the whole script:

python
import matplotlib.pyplot as plt

plt.style.use("seaborn-v0_8")                # softer, statistical look
plt.style.use("ggplot")                      # R/ggplot inspired
plt.style.use("dark_background")             # for dashboards on dark UIs
plt.style.use("default")                     # reset

# Combine — apply seaborn, then override font size
plt.style.use(["seaborn-v0_8", {"font.size": 12, "axes.titlesize": 14}])

For a one-off plot, use a context manager — no global state pollution:

python
with plt.style.context("dark_background"):
    fig, ax = plt.subplots()
    ax.plot([1, 2, 3], [1, 4, 9])
    plt.show()
# Output: a dark-background chart; the global style remains untouched after the block.
+ setup added so this can run · defines plt
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

plt = _AutoMock('plt')

9. Seaborn — When to Reach For It

Seaborn is matplotlib with statistical defaults and DataFrame awareness. The same plot that takes 8 lines in matplotlib often takes 2 in seaborn — particularly anything involving aggregation, grouping, or confidence intervals.

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

rng = np.random.default_rng(0)
df = pd.DataFrame({
    "day":   np.tile(np.arange(7), 100),
    "value": rng.standard_normal(700) + np.tile([1, 2, 1, 3, 2, 4, 3], 100),
    "group": np.repeat(["A", "B"], 350),
})

# Line plot with auto-aggregated confidence interval
sns.lineplot(data=df, x="day", y="value", hue="group", errorbar="ci")
plt.title("Mean value by day with 95% CI")
plt.show()
# Output: two lines (A and B), each showing the daily mean, surrounded by a shaded
# 95% confidence band. Legend appears automatically from the `hue` column.

# Bar with auto-aggregation across rows
sns.barplot(data=df, x="day", y="value", hue="group")
plt.show()
# Output: grouped bars per day, two colours for A and B, error bars showing CI.

# Pairwise scatter for an entire DataFrame
iris = sns.load_dataset("iris")
sns.pairplot(iris, hue="species", diag_kind="hist")
plt.show()
# Output: a grid of scatters (lower-tri off-diag) and histograms (diagonal), 
# coloured by species. Built in one line.

# Correlation heatmap with annotations
corr = iris.drop(columns="species").corr()
sns.heatmap(corr, annot=True, cmap="coolwarm", center=0, fmt=".2f")
plt.show()
# Output: 4x4 heatmap of feature correlations, each cell labelled with the value,
# diverging colour scale centred at 0.

When to reach for seaborn:

  • You're plotting from a DataFrame and want hue/size/style to come from columns.
  • You want automatic aggregation with confidence intervals (lineplot, barplot).
  • You want a quick statistical view (pairplot, heatmap, violinplot, kdeplot).

When to stay on matplotlib:

  • You need precise control over every visual element.
  • You're not plotting from a DataFrame.
  • You want a custom layout (gridspec, embedded panels, custom annotations).

A common pattern is seaborn for the data, matplotlib for the polish: build the plot with seaborn, then customise the resulting Axes (which seaborn returns) with matplotlib's OO API.


10. Colour — The Most-Botched Variable

Default matplotlib colours are not colour-blind safe. About 8% of men can't distinguish red and green; if your chart uses those for "positive" and "negative", you've excluded part of your audience.

python
import seaborn as sns
import matplotlib.pyplot as plt

palette = sns.color_palette("colorblind")
print(palette)
# A list of 10 RGB tuples — distinguishable for the major colour-blindness types

fig, ax = plt.subplots()
for i, c in enumerate(palette):
    ax.bar(i, 1, color=c)
plt.show()
# Output: 10 bars, each in a different colourblind-safe colour.

Rules:

  • Categorical data — qualitative palettes: "colorblind", "tab10", "Set2".
  • Sequential data (low → high) — sequential palettes: "viridis", "magma", "YlGnBu". Perceptually uniform.
  • Diverging data (centred around 0) — diverging palettes: "coolwarm", "RdBu_r".
  • Never use "jet". It's the matplotlib default from 2007, perceptually non-uniform, and makes data look more dramatic than it is.

Test your chart in greyscale (print preview is the cheapest way). If two important series become indistinguishable, change the colours — or change the marker style so colour isn't the only encoding.


11. Interactive — Plotly, Bokeh, Altair

Matplotlib produces static images. For dashboards, notebooks where users zoom and hover, or charts embedded in web apps, reach for:

  • plotly — interactive JSON-backed charts; great for hover tooltips and 3D; integrates with Dash for dashboards.
  • bokeh — interactive HTML/JS output; strong streaming and large-dataset support.
  • altair — declarative grammar (Vega-Lite under the hood); compact code for complex statistical charts.

For exploratory work in a notebook, plotly is usually the fastest path to "I can hover and see values". For production dashboards, plotly+Dash or bokeh+Panel are the common stacks. Matplotlib remains the right tool for static reports, papers, slides, and anything you'll print.


Common Mistakes

1. Pie charts for more than ~5 categories

Humans are bad at reading angles. A bar chart with the same data is easier to compare. Reserve pies for "two categories, one is dominant" stories, or skip them entirely.

2. Truncated y-axis exaggerating differences

python
# Misleading — y starts at 95, so a 1% change looks dramatic
ax.set_ylim(95, 100)

# Honest — y starts at 0 (or whatever the meaningful floor is)
ax.set_ylim(0, 100)
+ setup added so this can run · defines ax
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

ax = _AutoMock('ax')

If the data is bounded (e.g. percentages), start at 0 by default. Truncate only with explicit annotation: "Note: y-axis starts at 95%". Never silently — it's the single most common form of accidental dishonesty in business charts.

3. Too many colours / series on one plot

After ~6 series, the legend becomes a colour-matching puzzle and the lines visually overlap. Options: facet into small multiples (plt.subplots(2, 3)), highlight one series and grey out the rest, or aggregate into fewer groups.

4. Chart junk — 3D bars, shadows, gradients

Every visual element should carry information. 3D bars distort comparison (the back row looks shorter). Drop shadows add ink without data. Gradient fills imply variation that isn't there. Tufte's principle: maximise the data-ink ratio — pixels that show data, not decoration.

5. Not labelling units

"Revenue: 250" is meaningless. Dollars? Thousands? Millions? Customers? Always include units in the axis label: Revenue (M USD). If the unit changes per series (mass and length on the same plot), label each series, not the axis.

6. Bare plt.something() instead of ax.something()

Once you have two charts in the same script — or once you wrap a chart in a function — pyplot's "current axes" becomes ambiguous. Build everything on explicit ax from the start; never refactor pyplot-state code under deadline.


🎯 Your Turn — Two-Panel Distribution Plot

Write plot_distribution(values, title, save_path=None) that produces a single figure with two side-by-side subplots showing the same data two ways:

  • Left panel — a histogram with 30 bins, edge-coloured for readability.
  • Right panel — a boxplot of the same values.
  • A unified figure-level title above both panels.
  • Both panels labelled appropriately.
  • If save_path is provided, save with dpi=300 and bbox_inches="tight".

Expected behaviour:

python
import numpy as np
rng = np.random.default_rng(0)
data = rng.standard_normal(1000)

plot_distribution(data, title="Sample from N(0, 1)", save_path="dist.png")
# Output: one figure, 1x2 grid; histogram on the left, boxplot on the right,
# both titled, with "Sample from N(0, 1)" as the figure suptitle. File saved to dist.png at 300 dpi.
+ setup added so this can run · defines plot_distribution
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def plot_distribution(*_a, **_kw):
    print('-> plot_distribution() called')
    return _AutoMock('plot_distribution()')

Skeleton:

python
import matplotlib.pyplot as plt

def plot_distribution(values, title, save_path=None):
    # TODO 1: create a figure with two side-by-side axes
    # TODO 2: histogram on the left axis (30 bins, with edges)
    # TODO 3: boxplot on the right axis
    # TODO 4: label both axes and set the figure suptitle
    # TODO 5: call fig.tight_layout() to prevent overlap
    # TODO 6: if save_path provided, savefig with dpi=300 and bbox_inches="tight"
    # TODO 7: return fig so the caller can show or further customise
    ...
Hint 1 — Side-by-side panels fig, (ax_hist, ax_box) = plt.subplots(1, 2, figsize=(10, 4)) creates one figure with two axes, unpacked into named variables. The 1, 2 means one row, two columns. figsize matters — a too-narrow figure squashes both panels.
Hint 2 — Suptitle vs ax.set_title ax.set_title(...) labels a single panel. fig.suptitle(...) sits above the whole figure — that's what you want for the unified title. You can still title each panel ("Histogram", "Boxplot") so the reader knows which is which. Remember fig.tight_layout() at the end, or the suptitle will overlap the panel titles.
Show full solution
python
import matplotlib.pyplot as plt

def plot_distribution(values, title, save_path=None):
    """Two-panel histogram + boxplot of `values`, with a unified suptitle."""
    fig, (ax_hist, ax_box) = plt.subplots(1, 2, figsize=(10, 4))

    # Left — histogram
    ax_hist.hist(values, bins=30, edgecolor="black", color="#4a90d9", alpha=0.85)
    ax_hist.set_title("Histogram")
    ax_hist.set_xlabel("value")
    ax_hist.set_ylabel("frequency")
    ax_hist.grid(True, axis="y", linestyle="--", alpha=0.4)

    # Right — boxplot
    ax_box.boxplot(values, vert=True, patch_artist=True,
                   boxprops=dict(facecolor="#a8d0e6"))
    ax_box.set_title("Boxplot")
    ax_box.set_ylabel("value")
    ax_box.set_xticks([1])
    ax_box.set_xticklabels(["data"])

    fig.suptitle(title, fontsize=14, fontweight="bold")
    fig.tight_layout()

    if save_path is not None:
        fig.savefig(save_path, dpi=300, bbox_inches="tight")

    return fig


import numpy as np
rng = np.random.default_rng(0)

# Normal distribution
plot_distribution(rng.standard_normal(1000), title="N(0, 1)", save_path="normal.png")
plt.show()

# Skewed distribution — log-normal
plot_distribution(rng.lognormal(0, 1, size=1000), title="Log-normal — note the right tail")
plt.show()

# Bimodal — mixture of two normals
mixed = np.concatenate([rng.standard_normal(500) - 3, rng.standard_normal(500) + 3])
plot_distribution(mixed, title="Bimodal — histogram reveals what boxplot hides")
plt.show()

Why both panels for the same data? They show complementary information:

  • Histogram — the shape of the distribution: unimodal, bimodal, skewed, gaps. A boxplot smooths all of that into five numbers.
  • Boxplot — robust summary statistics: median, IQR, whisker range, outlier dots. A histogram doesn't visually flag outliers without a careful y-axis read.

The bimodal example is the killer demonstration: the boxplot looks like a vaguely normal distribution with wide IQR, while the histogram immediately reveals two separate peaks. Either alone can mislead; together they're hard to fool. This is the principle behind seaborn's displot and pairplot — show multiple views simultaneously.

Why return fig? So the caller can compose further — adjust styling, add annotations, embed in a multi-figure report — without you having to anticipate every option as a parameter. A function that builds and returns a figure is reusable; a function that builds and calls plt.show() is a one-off script.

Production touches you'd add for a real pipeline:

  • A small summary text box in a corner showing N, mean, median, std — the numbers a reader will look for anyway.
  • Sharing the data axis: in this layout, the histogram's x-axis and the boxplot's y-axis represent the same variable. Linking them (or aligning the ranges) reinforces that they're two views of one quantity.
  • Saving in both PNG (for slides) and PDF (for documents) from a single function call.
  • Optionally accepting a list of arrays for side-by-side comparison — at which point you've reinvented seaborn.displot, and you should just call that instead.

What You Learned

  • The fig, ax = plt.subplots() OO API beats the pyplot state machine for anything that lasts past today. Always operate on explicit Axes.
  • Six chart types cover most analytic questions: line, bar, histogram, scatter, boxplot, heatmap. Pick by what you're showing.
  • Every chart needs a title, axis labels with units, and (if multi-series) a legend. Sensible y-limits and explicit ticks fix most "looks weird" problems.
  • plt.subplots(rows, cols) + axes.flat for clean small-multiples layouts. gridspec for irregular ones.
  • Twin axes (ax.twinx()) for two genuinely correlated series with different scales — used sparingly.
  • Annotations: ax.text, ax.annotate, ax.axhline, ax.axvline for callouts and reference lines.
  • Save with dpi=300, bbox_inches="tight". Use PDF/SVG for vector output.
  • Style sheets (plt.style.use("seaborn-v0_8")) modernise the look in one line; context managers scope them.
  • Seaborn wins on statistical aggregation, DataFrame-aware plotting (hue=, pairplot, heatmap), and prettier defaults. Use it for data, fall back to matplotlib for polish.
  • Colour-blind safe palettes by default (sns.color_palette("colorblind")); sequential for ordered data, diverging for ±, never jet.
  • Interactive needs → plotly, bokeh, altair. Static reports → matplotlib.
  • Don't lie with charts: avoid truncated y-axes, pie charts past 5 categories, chart junk, missing units, and bare plt.something() in code that has to scale.

Next: walk through a real end-to-end analysis using everything from these three lessons — load with pandas, compute with numpy, visualise here, and ship the report. See the projects track for end-to-end workflows.