Several axes on one figure - the grid, the shared axes, and the layout that stops them colliding.
Overview
The grid
A single figure with multiple panels is called a subplot layout. Each panel is its own Axes object with its own data, limits, labels and legend. The panels share the figure's canvas and can share axes to make comparisons easier.
Subplots are the right approach when you want to compare the same kind of chart across groups, show related variables side by side, or combine different chart types into one display. A grid of small multiples -- the same chart repeated for each category -- is one of the most effective visual patterns, and subplots are how you build it in matplotlib.
plt.subplots(rows, cols) creates the grid and returns the figure and an array of Axes objects. From there, you index into the array to draw on each panel. The array follows NumPy conventions: axes[row, col] for a 2-D grid, axes[i] for a single row or column.
This page covers the basic grid, the shape surprise when the grid is one-dimensional, shared axes, unequal panel sizes with gridspec, the mosaic layout for named panels, insets, removing unused panels, and the spacing controls that keep the layout clean.
The most common issue with subplots is overlapping labels. Titles, axis labels and tick labels can collide when the panels are small. fig.tight_layout() adjusts the spacing automatically and fixes most cases. For finer control, gridspec parameters and constrained_layout=True give you explicit padding between panels.
Shared axes are worth knowing about early. When panels show the same variable, sharing the y axis gives them the same scale. Without it, the reader might compare bar heights across panels that use different ranges. sharey=True in the subplots call handles this. It also removes the redundant tick labels from inner panels, which reduces clutter.
The mosaic layout, available through plt.subplot_mosaic, lets you name each panel and arrange them in a text grid. That makes it easier to build layouts where one panel is wider or taller than the others. The editors on this page show both the regular grid and the mosaic approach.
fig, axes = plt.subplots(2, 3) creates a figure with six axes and returns them as a NumPy array.
axes[0, 2] is the top-right panel. axes.flat iterates in reading order. This Is what you want when the panels match to a flat list of groups:
for ax, name in zip(axes.flat, names):
...
zip stops at the shorter of the two. A grid with more cells than data leaves the extras blank. Visible. Often worth dropping with ax.drop() or hiding with ax.axis("off").
Worth knowing
subplots(r, c) returns an array of axes; axes.flat iterates in reading order.
The array is 2-D only for a true grid — 1xN gives 1-D and 1x1 gives a bare Axes. squeeze=False makes it always 2-D.
sharex/sharey are what make a grid a comparison; without them each panel scales to its own data.
add_gridspec with height_ratios handles panels of different sizes; gs[0, :] spans a row.
subplot_mosaic takes a picture of the layout as a string and returns a dict keyed by the letters.
tight_layout() or constrained_layout=True stop labels colliding — a figure cropped when saved is usually missing one.
Subplots
a few axes on one figure.
A grid of axes
subplots(rows, cols) returns an array you index or iterate.
example_01.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(0)
fig, axes = plt.subplots(2, 3, figsize=(9, 4))
print("axes is a", type(axes).__name__, "of shape", axes.shape)
for i, ax in enumerate(axes.flat):
ax.plot(rng.random(20).cumsum())
ax.set_title("panel %d" % i, fontsize=9)
fig.tight_layout()
print()
print("axes[0, 2] is the top-right panel.")
print("axes.flat iterates them in reading order, which is what you want")
print("when the panels correspond to a flat list of things.")
print()
print("With one row or one column it is 1-D; with 1x1 it is a single")
print("Axes, not an array - which is why fig, ax = plt.subplots() works.")
Output
squeeze, and the shape surprise
A 1xN grid gives a 1-D array, which breaks code written for 2-D.
example_02.pymatplotlib
import matplotlib.pyplot as plt
for r, c in [(1, 1), (1, 3), (3, 1), (2, 2)]:
fig, axes = plt.subplots(r, c, figsize=(2, 1))
shape = getattr(axes, "shape", "single Axes")
print("subplots(%d, %d) -> %s" % (r, c, shape))
plt.close(fig)
print()
fig, axes = plt.subplots(1, 3, figsize=(6, 1.6), squeeze=False)
print("squeeze=False always gives 2-D:", axes.shape)
print()
print("Code that does axes[0, 1] breaks on a 1-D result. squeeze=False")
print("makes the shape predictable, which matters in a function that")
print("takes the grid size as an argument.")
fig2, ax = plt.subplots(figsize=(4, 2))
ax.plot([0, 1], [1, 0])
ax.set_title("and one figure left open to show")
Output
Sharing axes
So panels can be compared, and the labels stop repeating.
example_03.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(1)
data = [rng.normal(loc, 1, 200) for loc in (0, 1, 2)]
fig, axes = plt.subplots(1, 3, figsize=(9, 2.6))
for ax, d in zip(axes, data):
ax.hist(d, bins=20)
fig.suptitle("independent axes - the panels are NOT comparable")
fig2, axes2 = plt.subplots(1, 3, figsize=(9, 2.6), sharex=True, sharey=True)
for ax, d in zip(axes2, data):
ax.hist(d, bins=20)
fig2.suptitle("sharex/sharey - now they are")
print("Without sharing, each panel scales to its own data, so a taller")
print("bar in one panel can represent a smaller number than a shorter")
print("bar in another. Sharing is what makes a grid a comparison.")
print()
print("It also hides the inner tick labels, which removes clutter.")
Output
Panels of different sizes
gridspec when the grid is not uniform.
example_04.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(2)
fig = plt.figure(figsize=(8, 4))
gs = fig.add_gridspec(2, 3, height_ratios=[2, 1], width_ratios=[2, 1, 1])
big = fig.add_subplot(gs[0, :])
big.plot(rng.random(80).cumsum())
big.set_title("spans the whole top row")
for i in range(3):
ax = fig.add_subplot(gs[1, i])
ax.bar(["a", "b"], rng.random(2))
ax.set_title("small %d" % i, fontsize=9)
fig.tight_layout()
print("gs[0, :] - row 0, every column")
print("gs[1, 0] - row 1, first column")
print("height_ratios makes the top row twice the height of the bottom.")
Output
subplot_mosaic reads better
A picture of the layout, in a string.
example_05.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(3)
fig, axd = plt.subplot_mosaic(
"""
AAB
CCB
""",
figsize=(8, 4))
axd["A"].plot(rng.random(50).cumsum())
axd["A"].set_title("A")
axd["B"].barh(["x", "y", "z"], rng.random(3))
axd["B"].set_title("B")
axd["C"].hist(rng.normal(size=300), bins=20)
axd["C"].set_title("C")
fig.tight_layout()
print("The string IS the layout. Repeated letters span cells.")
print("axd is a dict keyed by the letters, so panels have names")
print("rather than indices - which reads far better than gs[0, :2].")
Output
Making room
tight_layout and constrained_layout both stop labels colliding.
example_06.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(4)
fig, axes = plt.subplots(2, 2, figsize=(6, 4))
for ax in axes.flat:
ax.plot(rng.random(10))
ax.set_xlabel("a long x label")
ax.set_ylabel("a long y label")
fig.suptitle("no layout management - labels collide")
fig2, axes2 = plt.subplots(2, 2, figsize=(6, 4), constrained_layout=True)
for ax in axes2.flat:
ax.plot(rng.random(10))
ax.set_xlabel("a long x label")
ax.set_ylabel("a long y label")
fig2.suptitle("constrained_layout=True")
print("tight_layout() is called after drawing and adjusts once.")
print("constrained_layout=True is set at creation and keeps adjusting,")
print("which handles colorbars and suptitles better.")
print()
print("Neither is the default, and a figure that looks fine on screen")
print("and cropped when saved is almost always missing one of them.")
Output
The shape surprise
The returned array's shape isn't always what code expects.
subplots(2, 3) gives a (2, 3) array. subplots(1, 3) gives a 1-D array of three. subplots(1, 1) gives a single Axes, not an array at all. Which is what makes fig, ax = plt.subplots() work.
That is convenient interactively and awkward in a function that takes the grid size as a param. axes[0, 1] fails on the 1-D case.
squeeze=False forces a 2-D array always. In reusable code it's the right default, and it costs one arg.
Sharing
sharex=True and sharey=True make the panels use the same limits.
This isn't a cosmetic setting. Without it, each panel autoscales to its own data. A bar that reaches the top of one panel may represent a smaller number than a shorter bar in the panel beside it. The grid looks like a compare and isn't one.
Sharing also hides the inner tick labels. This Drops a great deal of repetition from a grid.
sharex="col" and sharey="row" share within columns or rows. This Suits a mat where each row is a other quantity.
The trade-off: with shared axes, a panel whose data occupies a small part of the shared range is compressed. When the panels truly have other scales, small multiples with split axes and clearly labelled ranges are more honest. But then the reader must be told not to compare heights right.
Uneven grids
fig.add_gridspec(rows, cols) creates a grid you then slice:
gs = fig.add_gridspec(2, 3, height_ratios=[2, 1])
big = fig.add_subplot(gs[0, :]) # whole top row
small = fig.add_subplot(gs[1, 0]) # bottom left
height_ratios and width_ratios make rows and columns other sizes. This Is how you give a main chart more space than the supporting ones.
subplot_mosaic
plt.subplot_mosaic takes a picture of the layout:
fig, axd = plt.subplot_mosaic("""
AAB
CCB
""")
Repeated letters span cells, and the result is a dict keyed by the letters. axd["A"] is far easier to follow than gs[0, :2], and the layout is visible in the source rather than coded in slice math.
A . in the string leaves a cell empty.
It's the most readable way to build an uneven layout. Worth preferring wherever it fits.
Layout
Labels, titles and tick text are drawn outside the axes. Matplotlib doesn't account for them when placing panels. The result is shared labels, or a title running off the top.
Two methods fix it.
fig.tight_layout() is called after all is drawn and adjusts spacing once. It's simple and works for most cases. It can be confused by artists added later. It doesn't handle colorbars above all well.
constrained_layout=True is set when the figure is created and keeps adjusting as things are added. It handles colorbars, suptitles and legends outside the axes better. It's the one to reach for on a complex figure.
Neither is on by default. fig.subplots_adjust(hspace=..., wspace=...) sets spacing manually when you want precise control.
The symptom of missing layout management is a figure that looks acceptable on screen and comes out cropped when saved. Because savefig uses the figure's declared size, not what the screen happened to show. bbox_inches="tight" on savefig is the other half of that fix.
Removing unused panels
A 3×4 grid holding ten charts leaves two empty boxes with ticks and spines. This Look like a mistake.
ax.drop() deletes the axes fully. ax.axis("off") keeps it but hides all. This Preserves the spacing. handy when the grid should stay rectangular.
for ax in axes.flat[len(items):]:
ax.remove()
The empty cell is also a fair place for a legend or a note, using ax.axis("off") and ax.legend(...) with handles gathered from the other panels.
Titles and labels across a grid
With shared axes, per-panel axis labels are repetition. fig.supxlabel and fig.supylabel label the grid once.
Panel titles remain handy and should be small. They're finding the panel, not heading a chart. fontsize=9 and loc="left" reads well.
For a formal figure, lettering the panels is standard:
for letter, ax in zip("abcdef", axes.flat):
ax.text(0.02, 0.95, letter, transform=ax.transAxes,
fontweight="bold", va="top")
Axes coords, so the letter stays in the corner even so of the data.
Iterating in the right order
axes.flat iterates row by row. This Matches how the panels will be read.
zip(axes.flat, items) pairs them and stops at the shorter. This Is convenient and silently drops items if the grid is too small. Asserting the sizes match is one line and blocks a chart quietly missing a group:
assert len(items) <= axes.size
For a grid indexed by two vars, axes[i, j] with clear loops is clearer than flattening. The spot then carries meaning.
When not to use subplots
Small multiples are great for checking the same reading across groups.
They're poor when the panels show other quantities on other scales. The shared layout implies a comparability that the axes don't support. Four panels showing revenue, headcount, delay and satisfaction are four charts that happen to be adjacent. Separating them. Or at least not sharing axes and labelling each scale clearly — is more honest.
They're also poor past about twelve panels. Here Each turns too small to read. At that point the answer is often to reduce what is being shown, not to shrink the panels further.
Figure size for a grid
The figure size should scale with the grid. A 2×3 grid in the same six inches as a single chart gives panels a third the size. Text that was comfortable turns proportionally huge.
A fair rule is to keep the per-panel size roughly constant:
Then adding a row makes the figure taller rather than making all smaller.
Shared axes in detail
sharex=True links the axes objects. So Changing the limits on one changes all of them. Which is convenient interactively and at times surprising in a script. Here An autoscale on one panel silently rescales the rest.
ax.label_outer() hides the tick labels on inner panels. This sharex/sharey do on its own but which is needed when you build the grid by hand.
sharex="col" links within columns only. This Suits a grid where columns are other quantities and rows are groups.
To share later rather than at creation, ax2.sharex(ax1) exists. The older ax2.get_shared_x_axes().join(ax1, ax2) appears in existing code.
A grid that reads
The details that make a grid of panels look seen as:
steady panel titles, small and left-aligned.
One axis label per grid, via supxlabel/supylabel.
Shared limits, so the compare is valid.
A steady colour across panels for the same series, or a single colour if each panel is one thing.
Empty cells removed, not left as empty boxes.
A common annotation style — if one panel has a link line, they all should.
On their own trivial. Together they're the gap between a grid that reads as one figure and one that reads as a few charts stuck together.
Building a grid programmatically
Most real grids are made from data rather than written out. A few patterns make that solid.
squeeze=False promises a 2-D array what the numbers work out to, so axes.flat always behaves.
Then pair, draw, and clean up the remainder:
for ax, item in zip(axes.flat, items):
draw(ax, item)
for ax in axes.flat[n:]:
ax.remove()
The figure size scaling with the grid is what keeps the text a constant size scaled to each panel, rather than shrinking as rows are added.
And because zip stops at the shorter arg, asserting n <= axes.size is worth the line. Else a grid that is too small silently drops groups.
Subplots, in summary
subplots(r, c) returns an array. axes.flat iterates in reading order. squeeze=False makes the shape predictable.
sharex and sharey are what turn a grid into a compare. Without them each panel scales to its own data and the heights mean other things.
gridspec and subplot_mosaic handle uneven layouts. The mosaic string is far easier to read than slice math.
tight_layout or constrained_layout stop labels colliding. A figure that saves cropped is often missing one.
Label the grid once with supxlabel rather than each panel, drop unused cells. Scale the figure size with the number of panels.
And small multiples are the right answer far more often than one crowded chart. They're the display that makes many checks possible at once.
Grids in practice
Three layouts cover nearly all real grids.
A row of two or three, for a before-and-after or a small set of related views. Wide, shared y, one shared axis label.
A square-ish grid, for small multiples over a cat var. Shared both ways, panel titles small, figure size scaled by the grid.
A main chart plus supporting ones, built with subplot_mosaic or gridspec and unequal ratios. The main panel gets two-thirds of the height and the supporting row the rest.
The fourth layout — a grid of unrelated charts — is common and often a mistake. Panels in a grid are read as close. Four charts of other quantities on other scales are four figures that happen to be adjacent. Either give them split figures, or make the gap clear with clearly labelled split scales and no shared axes.
The question that decides it: would a reader be right to compare the panels to each other? If yes, share the axes. If no, they probably shouldn't be in a grid.
Sharing a legend
A grid where each panel repeats the same legend wastes space and focus.
Handles are taken from any one panel. They're all the same series. fig.legend places it scaled to the figure rather than an axes.
Room has to be made for it. This constrained_layout does on its own and subplots_adjust does by name.
The same applies to a colorbar: one bar serving the whole grid. This Also enforces the shared scale the panels need to be close.
One more thing
fig.align_ylabels() lines up the y-axis labels across a column of panels. This Else sit at other distances from the axes based on how wide each panel's tick labels are.
It's a small alignment that is very visible once noticed. align_xlabels does the same flatly. On a grid where the panels have other value ranges, it's the gap between a column of labels that reads as one and one that looks ragged.
Subplots: the short version
A grid is a claim that the panels belong together. Sharing the axes is what makes the claim true. Labelling the grid once rather than each panel is what makes it read as one figure.
The three things that most often go wrong are an unshared y axis making incomparable panels look close, a figure size that didn't grow with the grid so all is cramped. Empty cells left as bare boxes. All three are one line each.
Questions people ask about Subplots
Why does plt.subplots return two things? It returns the figure and the axes. Unpack as fig, ax = plt.subplots().
Why can I not index my axes with ax[0]? With one row and one column you get a single Axes. With a row or column you get a 1D array. A grid gives a 2D array. Pass squeeze=False to always get 2D.
How do I share an axis between panels? Pass sharex=True or sharey=True to plt.subplots. Inner tick labels are hidden automatically.
How do I make panels of different sizes? Use subplot_mosaic or GridSpec with width_ratios and height_ratios.
How do I hide unused panels? Call ax.set_visible(False) or ax.axis('off') on each spare axes.
What is the difference between plt.subplot and plt.subplots?subplot adds one axes to the current figure. subplots creates the figure and the whole grid at once.
Reading the code back
A grid is created in one call and finished in three. Share the axes, label the grid once. Drop what is unused. The figure size should be found from the grid rather than fixed. Adding a row makes the figure taller instead of making each panel smaller. Those four choices are what split a grid that reads as one figure from a few charts that happen to be adjacent.
Check yourself
0 of 4
Answer without scrolling back up.
What does `plt.subplots(1, 3)` return for the axes?
1x1 gives a bare Axes, which is what makes `fig, ax = plt.subplots()` work. squeeze=False forces 2-D always.
Why does `sharey=True` matter for a grid of histograms?
The grid looks like a comparison and is not one. Sharing also hides the inner tick labels.
What does the string in `subplot_mosaic` represent?
It returns a dict keyed by the letters, so axd['A'] beats gs[0, :2] for readability.
A figure looks fine on screen but is cropped when saved. What is missing?
savefig uses the figure's declared size, not what the screen showed, and labels are drawn outside the axes.
Cheat sheet
Subplots
Create grids of subplots with plt.subplots and GridSpec in matplotlib. Place multiple axes on one figure with controlled sizes and spacing.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.