datarekha

Figure and Axes

The matplotlib hierarchy you actually need to know. State-machine vs object-oriented APIs — and why every serious notebook uses the latter.

6 min read Beginner Storytelling with Visualisation Lesson 2 of 12

What you'll learn

  • The Figure / Axes / Axis hierarchy
  • The two matplotlib APIs and when each fails
  • The `fig, ax = plt.subplots()` pattern you should use every time

Before you start

The previous lesson leaned on one unexplained line — fig, axes = plt.subplots(2, 2) — and then drew onto the axes it handed back. That fig / axes pairing isn’t incidental; it’s the entire mental model matplotlib is built on, and almost every confusing matplotlib error traces back to not having it straight. Learn the hierarchy once and the library stops being mysterious.

Matplotlib has a reputation for being verbose. Most of that reputation comes from people mixing two different APIs without realizing it. Learn the hierarchy once and the library becomes mechanical.

The hierarchy

Three nested concepts:

  • Figure — the entire canvas. One window, one saved PNG, one HTML output. A figure has a size in inches and a DPI.
  • Axesone plot area inside the figure, with its own coordinate system, title, and axis labels. A figure can have many Axes objects (a 2×2 grid is four of them). “Axes” is matplotlib’s word for “a single chart.”
  • Axis — the x or y number line on a given Axes, with its ticks and label. You’ll touch these through ax.xaxis / ax.yaxis for fine-tuning.

They’re separate objects because one canvas often needs multiple independent coordinate systems — a 2×3 grid of subplots is one Figure containing six Axes, each with its own x/y scales, titles, and data. Keeping them separate lets you address each chart individually without fighting over a single global state.

The confusing word is “Axes.” It’s singular — one Axes object is one chart. A figure with four charts has four Axes.

The two APIs

Matplotlib ships two APIs that do mostly the same things:

# 1. pyplot state-machine — implicit "current figure"
plt.plot([1, 2, 3], [4, 5, 6])
plt.title("Hello")
plt.show()

# 2. Object-oriented — explicit figure and axes
fig, ax = plt.subplots()
ax.plot([1, 2, 3], [4, 5, 6])
ax.set_title("Hello")
plt.show()

For a one-off scratch plot, the state-machine version is fine. The moment you want two subplots, a custom layout, an inset, or shared axes, the implicit “current figure” gets confusing fast — you end up calling plt.gca() (get current axes) and praying it’s the one you meant.

The OO API takes one extra line and never gets ambiguous. Use it everywhere.

plt.plot and ax.plot are not the same call. plt.plot routes to whichever Axes matplotlib considers “current” at that moment — which can silently be the wrong one if you have multiple subplots or call plot from inside a function. ax.plot is explicit: it always targets the Axes you hold in your hand.

The pattern, fully worked

import numpy as np
import matplotlib.pyplot as plt

# Simulated monthly active users for a SaaS product
months = np.arange(1, 13)
mau = np.array([1.2, 1.5, 1.9, 2.4, 3.0, 3.7, 4.3, 4.6, 4.8, 5.2, 5.8, 6.5])

# 1. Make the figure and ONE axes inside it.
fig, ax = plt.subplots(figsize=(8, 4))

# 2. Plot on the axes.
ax.plot(months, mau, marker="o", color="steelblue", linewidth=2)

# 3. Label everything on the axes.
ax.set_xlabel("Month")
ax.set_ylabel("MAU (millions)")
ax.set_title("Monthly active users — 2025")

# 4. An annotation right on the axes.
ax.annotate("Product Hunt launch",
            xy=(5, 3.0), xytext=(6.2, 2.0),
            arrowprops=dict(arrowstyle="->", color="black"))

# 5. Light grid for readability.
ax.grid(True, linestyle="--", alpha=0.4)

# 6. Optional but nice — remove the top and right spines.
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)

fig.tight_layout()
plt.show()
Line plot of monthly active users rising from 1.2M in month 1 to 6.5M in month 12, with a marker at each month and an annotation arrow pointing up-left from the label 'Product Hunt launch' to the data point at month 5 (3.0M).

The label sits below-right at (6.2, 2.0); the arrow points up-left to the data point at (5, 3.0).

The arrow points up-left — from the label at (6.2, 2.0) toward the tip at (5, 3.0), which is up (higher y) and to the left (lower x). Every line in that code operates on ax, an explicit object. There’s no hidden “current axes” state, so this code can be reused inside a function, a loop, or alongside other plots without surprises.

The translation table

If you’ve inherited pyplot-style code, this is the renaming you’ll do 99% of the time:

pyplot (state)OO (object-oriented)
plt.plot(x, y)ax.plot(x, y)
plt.scatter(...)ax.scatter(...)
plt.title("…")ax.set_title("…")
plt.xlabel("…")ax.set_xlabel("…")
plt.ylabel("…")ax.set_ylabel("…")
plt.xlim(0, 10)ax.set_xlim(0, 10)
plt.legend()ax.legend()
plt.xticks(...)ax.set_xticks(...)
plt.grid(True)ax.grid(True)

Notice the pattern: OO methods that set a property are named set_xxx. OO methods that do something — plot, scatter, legend, grid — keep the same name.

Saving the figure

fig.savefig("mau.png", dpi=200, bbox_inches="tight")
fig.savefig("mau.svg")                          # vector, scales perfectly
fig.savefig("mau.pdf")                          # great for reports

bbox_inches="tight" trims the whitespace around the figure — almost always what you want for embedding into docs and slides.

figsize, DPI, and font — see the effect live

figsize=(width, height) is in inches. At dpi=100 (the default), an 8×4 figure becomes 800×400 pixels. Raise DPI for print; leave it at 100 for notebooks. Font size controls tick labels, axis labels, and the title — too large and they clip or crowd the data area.

TryFigure anatomy

Drag the sliders — see what figsize, DPI, and font actually do

fig, ax = plt.subplots(
    figsize=(8, 4),
    dpi=100,
)

ax.tick_params(labelsize=12)
ax.set_xlabel("Month", fontsize=12)
ax.set_ylabel("Value",  fontsize=12)
ax.set_title("MAU trend", fontsize=14)
Output size800 × 400 px
Preview (scaled)
1234567802468MonthValueMAU trend

In one breath

Matplotlib nests three objects: the Figure (the whole canvas, one saved file), the Axes (one plot area inside it — the confusing singular word for “a single chart,” so a 2×3 grid is six Axes), and the Axis (the x or y number line). It ships two APIs: the pyplot state-machine (plt.plot, plt.title) that guesses the “current” axes, and the object-oriented API (ax.plot, ax.set_title) that targets an explicit object. Start every plot with fig, ax = plt.subplots(...) and call methods on ax — it costs one line and never gets ambiguous, which matters the moment you have multiple subplots or plot from inside a function. OO setters are prefixed set_ (set_title, set_xlabel); the doing verbs (plot, scatter, legend, grid) keep their names.

Practice

Quick check

0/3
Q1How many Axes objects are in a figure with a 2×3 grid of subplots?
Q2Which is the OO equivalent of `plt.title('foo')`?
Q3Why prefer the OO API for anything beyond a one-line plot?

A question to carry forward

You now have the scaffold: fig, ax = plt.subplots(), then everything onto ax. And in the worked example we reached for ax.plot — a connected line — without justifying it. That was right for monthly users, where the points have a natural order. But matplotlib hands you a second workhorse, ax.scatter, where each point stands alone — and using the wrong one produces a meaningless tangle.

So the question to carry forward is: when do you connect the dots, and when do you leave them apart? The next lesson, line and scatter plots, draws the line between matplotlib’s two most-used charts, shows how to encode up to four dimensions at once in a single scatter, and gives you the one-question test that settles which to reach for every time.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions

Related lessons

Explore further

Skip to content