You already met the containers in the parent note : Figure (the whole page), Axes (one plot with its own rulers), Artist (anything drawn). This page does one thing: it walks through every kind of situation you can hit when you create and address these containers — and it makes you predict the answer before revealing it.
Before we start, one word we will lean on constantly:
Definition "Addressing" a container
To address a container means: writing the exact name of the box you want to talk to, e.g. ax, axes[0, 1], or fig. The opposite is relying on Matplotlib's hidden current box (what plt.plot uses). Every bug on this page comes from addressing the wrong box — or not addressing at all.
Matplotlib's container model has a small, finite set of situations. If you can handle every cell below, nothing can surprise you. Each example that follows is tagged with the cell it covers.
#
Case class
The specific situation
Covered by
A
Single plot, OO
nrows*ncols == 1 → one bare Axes
Ex 1
B
Return-shape: 1-D
one row or one column → 1-D array
Ex 2
C
Return-shape: 2-D
grid → 2-D array axes[r][c]
Ex 3
D
Degenerate / zero
empty figure, no Axes, or blank plot
Ex 4
E
Sign/order trap
pyplot's hidden current points at the wrong box
Ex 5
F
Figure-level vs Axes-level
which action belongs to fig, which to ax
Ex 6
G
Real-world word problem
temperature dashboard, save one file
Ex 7
H
Exam-style twist
mixed API, indexing a flattened grid
Ex 8
The "sign/quadrant" idea here is not about numbers — it is about which box is current . That hidden state is the exact analogue of a sign trap: it silently flips and ruins your output with no error. We hunt it in cells D and E.
Worked example The safe default
Create one Figure with one Axes, draw sin x on it, label both rulers, save the file.
Forecast: how many objects does plt.subplots() return, and what type is the second one? (Guess before reading.)
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace( 0 , 2 * np.pi, 200 )
fig, ax = plt.subplots( figsize = ( 5 , 3 )) # step 1
ax.plot(x, np.sin(x), label = "sin" ) # step 2
ax.set_xlabel( "x [rad]" ) # step 3
ax.legend() # step 4
fig.savefig( "one.png" , dpi = 150 ) # step 5
fig, ax = plt.subplots() — Why? nrows*ncols = 1*1 = 1, so the second return value is a single Axes, not an array. We unpack it straight into ax.
ax.plot(...) — Why? a Line2D Artist must live inside an Axes; we address ax so there is no doubt which box receives it.
ax.set_xlabel(...) — Why? labels are Axes-level Artists (each plot has its own rulers), so they must be set on the Axes.
ax.legend() — Why? a legend reads the label= of Artists in this Axes only .
fig.savefig(...) — Why? saving writes the whole canvas to a file, which is a Figure-level job. See Saving figures — dpi, formats, bbox .
Verify: type(ax).__name__ contains "Axes" and it is not a NumPy array. The returned tuple has length 2 . ✅
plt.subplots(1, 3)
Make three side-by-side panels. Address the middle one.
Forecast: is axes a single Axes, a 1-D array, or a 2-D array? What index selects the middle panel?
fig, axes = plt.subplots( 1 , 3 , figsize = ( 9 , 3 )) # step 1
axes[ 1 ].plot([ 0 , 1 ], [ 0 , 1 ]) # step 2
axes[ 1 ].set_title( "middle" ) # step 3
Why 1-D? exactly one of nrows, ncols equals 1, so Matplotlib "collapses" the grid into a 1-D array of length 3 . No row/column index is needed — just one number.
axes[1] — Why index 1? counting from 0 , the panels are 0 , 1 , 2 ; the middle is index 1 .
Title lands on that middle Axes only.
Verify: axes.ndim == 1 and axes.shape == (3,). ✅
plt.subplots(2, 2)
Four panels. Draw a line top-left, a scatter bottom-right, give the whole page one title.
Forecast: what is axes.shape? What index is the bottom-right cell?
fig, axes = plt.subplots( 2 , 2 , figsize = ( 6 , 5 ))
axes[ 0 , 0 ].plot([ 0 , 1 ], [ 0 , 1 ]) # top-left (row 0, col 0)
axes[ 1 , 1 ].scatter([ 1 , 2 ], [ 3 , 1 ]) # bottom-right (row 1, col 1)
fig.suptitle( "Grid" ) # figure-wide title
fig.tight_layout()
Why 2-D? both nrows and ncols exceed 1, so the array keeps two indices: axes[row, col].
axes[0, 0] — top-left: row 0 (top) column 0 (left).
axes[1, 1] — bottom-right: row 1 (bottom, rows count downward) column 1 (right).
fig.suptitle — Why fig? one title spanning all four Axes is a Figure-level Artist.
Verify: axes.shape == (2, 2) and the number of Axes equals 2 × 2 = 4 . ✅
See Matplotlib — subplots and GridSpec layout for uneven grids.
Worked example The empty and the invisible
Two sub-cases of "nothing drawn":
4a — Figure with no Axes.
fig = plt.figure( figsize = ( 4 , 3 )) # a canvas, zero plots on it
fig.savefig( "empty.png" )
Forecast: how many Axes does this Figure contain?
plt.figure() makes only the outer container. Why blank? no Axes were requested, so len(fig.axes) == 0 — an empty scrapbook page.
Verify (4a): len(fig.axes) == 0. ✅
4b — Blank plot despite code running (the classic).
fig, ax = plt.subplots()
plt.plot([ 0 , 1 , 2 ], [ 0 , 1 , 4 ]) # DANGER: not ax.plot!
Forecast: does the line appear inside ax, or somewhere else?
plt.plot addresses the hidden current Axes . Here that is ax (it was just created), so it happens to work — but this is fragile: add one more plt.subplots() and the current box moves. The lesson: address ax explicitly. This is the mechanism behind the "blank figure" bug from the parent note.
Verify (4b): right after plt.subplots(), plt.gca() is ax → the current Axes is ax. ✅ (True only because nothing else was created afterwards — that's the trap.) See Matplotlib — pyplot vs object-oriented API .
plt.plot calls, one panel
plt.plot([ 0 , 1 ], [ 0 , 1 ]) # first curve
plt.plot([ 0 , 1 ], [ 1 , 0 ]) # second curve
Forecast: do you get one Axes with two lines, or two Axes with one line each?
First plt.plot finds no current Axes, so gca() creates one and draws into it.
Second plt.plot finds that same current Axes still active, so it adds a second line to it — no new plot is made. Why? the current box never changed; pyplot keeps piling Artists into it.
Result: one Axes, two Line2D Artists. To force separate panels you must address new boxes: fig2, ax2 = plt.subplots().
Verify: after both calls, len(plt.gcf().axes) == 1 (one Axes) and that Axes holds 2 lines. ✅
Worked example Who owns which method?
Sort these calls into "belongs to fig" vs "belongs to ax":
savefig, set_xlabel, suptitle, plot, legend, tight_layout.
Forecast: write your split before revealing.
Call
Owner
Why
savefig
fig
writes the whole canvas
suptitle
fig
one title over all Axes
tight_layout
fig
re-packs all Axes to avoid overlap
plot
ax
a Line2D lives inside one Axes
set_xlabel
ax
each Axes has its own rulers
legend
ax
reads labels of Artists in that Axes
Rule of thumb: if it affects the whole page or the file , it's fig.…; if it affects one plot region or its contents , it's ax.….
Verify: the count of Figure-level calls in the list is 3 (savefig, suptitle, tight_layout). ✅
Worked example A weather dashboard
A station logs 24 hourly temperatures. You must show them as a line in the left panel and their histogram in the right panel, put the city name across the top, and export exactly one PNG for a report.
Forecast: how many Figures, how many Axes, how many savefig calls?
temps = np.array([ 12 , 11 , 11 , 10 , 10 , 11 , 13 , 16 , 19 , 21 , 23 , 24 ,
25 , 25 , 24 , 23 , 21 , 19 , 17 , 16 , 15 , 14 , 13 , 12 ])
hours = np.arange( 24 )
fig, axes = plt.subplots( 1 , 2 , figsize = ( 9 , 3 )) # 1 fig, 2 axes (1-D)
axes[ 0 ].plot(hours, temps) # left: time series
axes[ 0 ].set_xlabel( "hour" )
axes[ 1 ].hist(temps, bins = 6 ) # right: distribution
fig.suptitle( "Delhi — 24h" ) # page-wide title
fig.tight_layout()
fig.savefig( "dashboard.png" , dpi = 150 ) # ONE file
One Figure — the whole report image is a single file.
Two Axes in a 1-D array (one row, two cols → cell B rules).
axes[0] / axes[1] — address each panel explicitly; no "current box" guessing.
suptitle on fig, one savefig on fig.
Verify: number of Axes = 2 ; there are 24 data points; the mean of temps rounds to 17 . ✅ (NumPy supplies the arrays.)
Worked example Loop over a 2×3 grid with one index
You have 6 datasets and want to plot each in its own panel using a single for loop. Indexing axes[r, c] needs two counters — annoying. The trick is .flat.
Forecast: how many panels, and what does axes.flat give you?
fig, axes = plt.subplots( 2 , 3 , figsize = ( 9 , 5 ))
for i, ax in enumerate (axes.flat): # flat = 1-D view, length 6
ax.plot([ 0 , 1 ], [ 0 , i]) # each panel gets a steeper line
ax.set_title( f "panel { i } " )
axes.shape == (2, 3) → 2 × 3 = 6 Axes.
axes.flat — Why? it gives a 1-D iterator over all 6 Axes in row-major order (row 0 left→right, then row 1). Now one loop counter i (from 0 to 5 ) addresses every panel.
enumerate pairs each Axes with its index, so panel 0 … panel 5 are titled in order.
Verify: total panels = 6 ; the last index visited is 5 ; axes.flat yields exactly 6 items. ✅
Recall Did every cell get covered?
A → Ex 1 · B → Ex 2, 7 · C → Ex 3 · D → Ex 4 · E → Ex 5 · F → Ex 6 · G → Ex 7 · H → Ex 8. Every return-shape (single / 1-D / 2-D), the degenerate/blank cases, the hidden-current-box trap, the fig-vs-ax split, a real dashboard, and the flatten twist — all shown.
Return-shape of plt.subplots(1, 4)? ::: A 1-D array of length 4 (shape == (4,)).
Return-shape of plt.subplots(3, 2)? ::: A 2-D array axes[r, c] with shape == (3, 2).
Return type of plt.subplots() (no args)? ::: A single Axes (not an array), since 1×1 = 1.
Why can two plt.plot calls end up in one panel? ::: Both address the same hidden current Axes; it never changed.
Best way to loop over every panel of a grid? ::: Iterate axes.flat — a 1-D view of all Axes.
Which owns savefig, fig or ax? ::: fig — saving the whole canvas is Figure-level.
5.4.15 Matplotlib — figure - axes architecture (Hinglish)
Matplotlib — pyplot vs object-oriented API
Matplotlib — subplots and GridSpec layout
Matplotlib — Artists, Line2D and styling
NumPy — arrays as plot data
Saving figures — dpi, formats, bbox
Scientific Computing (Python)