Subplots and Layout
Subplots and Layout
Core Principle: Multi-panel figure layouts in Matplotlib are constructed using
plt.subplotsfor uniform grids,GridSpecfor custom span ratios, orsubplot_mosaicfor visual ASCII layout definitions, optimized via constrained layout engines.
Regular grids
fig, axes = plt.subplots(2, 3, figsize=(12, 6))
axes[0, 1].plot(x, y) # 2-D indexing
for ax in axes.flat: # iterate regardless of shape
ax.set_xlabel("x")
fig, axes = plt.subplots(1, 3, sharey=True) # shared scale
fig, axes = plt.subplots(3, 1, sharex=True, figsize=(6, 9))
fig, axes = plt.subplots(2, 2, squeeze=False) # always 2-D, even for 1×1
squeeze=False matters in library code: with default squeeze=True, plt.subplots(1, 1) returns a bare Axes, plt.subplots(1, 3) returns a 1-D array, and plt.subplots(2, 3) a 2-D array. Code that handles a variable number of panels should force 2-D.
Irregular layouts: subplot_mosaic
The best modern option: you draw the layout as ASCII art:
Traceback (most recent call last):
File "/Users/leviosman/Developer/python-for-bio/webapp/scripts/curriculum/generate_cell_outputs.py", line 126, in run_cell_capture
exec(compile(head, "<cell>", "exec"), namespace)
~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<cell>", line 12, in <module>
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/matplotlib/axes/_axes.py", line 1794, in plot
self.add_line(line)
~~~~~~~~~~~~~^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/matplotlib/axes/_base.py", line 2472, in add_line
self._update_line_limits(line)
~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/matplotlib/axes/_base.py", line 2509, in _update_line_limits
path = line.get_path()
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/matplotlib/lines.py", line 1054, in get_path
self.recache()
~~~~~~~~~~~~^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/matplotlib/lines.py", line 682, in recache
y = _to_unmasked_float_array(yconv).ravel()
~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/matplotlib/cbook.py", line 1407, in _to_unmasked_float_array
return np.asanyarray(x, float)
~~~~~~~~~~~~~^^^^^^^^^^
TypeError: float() argument must be a string or a real number, not 'ellipsis'
Readable, self-documenting, and far easier than the equivalent GridSpec. Use . for an empty cell.
This code completed without producing output.
The list-of-lists form lets you use meaningful names longer than one character.
GridSpec
The lower-level mechanism, still useful for fine control of relative sizes:
This code completed without producing output.
height_ratios / width_ratios are the reason to reach for GridSpec: a genome-browser-style figure with a tall coverage track over a short gene-model track is exactly this.
Layout engines
<cell>:2: UserWarning: The figure layout has changed to tight
layout="constrained" solves overlapping labels and titles by solving a layout problem, and unlike tight_layout() it works with colorbars, suptitles and legends outside the axes. Set it at creation time; it then applies on every draw and every save.
tight_layout() is a one-shot adjustment. If you add a colorbar afterwards, the layout is wrong again.
Manual control, if you need it:
<cell>:1: UserWarning: This figure was using a layout engine that is incompatible with subplots_adjust and/or tight_layout; not calling subplots_adjust.
hspace/wspace are the gaps between panels, in units of average panel size.
Insets
axin = ax.inset_axes([0.6, 0.6, 0.35, 0.35]) # [x, y, w, h] in Axes coords
axin.plot(x, y)
ax.indicate_inset_zoom(axin, edgecolor="black") # draws the connector lines
indicate_inset_zoom draws the box and leader lines automatically once the inset's limits are set: a clean way to show a zoomed region of a coverage track or an embedding.
Colorbars in a grid
im = axes[0].imshow(m, cmap="viridis")
fig.colorbar(im, ax=axes[0]) # steals space from that panel
fig.colorbar(im, ax=axes.ravel().tolist()) # one bar for the whole grid
fig.colorbar(im, ax=axes, shrink=0.6, label="log2 CPM")
# a dedicated colorbar axes for exact control
cax = fig.add_axes([0.92, 0.15, 0.02, 0.7])
fig.colorbar(im, cax=cax)
A shared colorbar for a grid only makes sense if the panels share a colour scale. Pass the same vmin/vmax to every imshow, or the shared bar is a lie.
Panel labels
This code completed without producing output.
Using transform=ax.transAxes puts the label at a consistent position relative to each panel regardless of the data range. See Matplotlib Artists.
A publication multi-panel figure
import matplotlib.pyplot as plt, seaborn as sns
sns.set_theme(style="ticks", context="paper", font_scale=0.9)
fig, axd = plt.subplot_mosaic(
[["pca", "pca", "volcano"],
["heat", "heat", "volcano"],
["box", "box", "box"]],
figsize=(7.2, 8), layout="constrained", # 7.2in = double column
)
sns.scatterplot(data=pca, x="PC1", y="PC2", hue="condition", ax=axd["pca"], s=40)
volcano(de, ax=axd["volcano"])
sns.heatmap(top_mat, cmap="vlag", center=0, ax=axd["heat"],
cbar_kws=dict(label="z-score"))
sns.boxplot(data=long, x="gene", y="expr", hue="condition", ax=axd["box"])
sns.stripplot(data=long, x="gene", y="expr", hue="condition",
dodge=True, color="black", size=2, legend=False, ax=axd["box"])
for k, ax in axd.items():
ax.text(-0.08, 1.06, k[0].upper(), transform=ax.transAxes,
fontsize=11, fontweight="bold")
sns.despine(fig=fig)
fig.savefig("figure2.pdf", bbox_inches="tight", dpi=300)
plt.close(fig)
Note figsize=(7.2, 8): sized for a double-column journal figure, so the 8 pt fonts will be 8 pt in print. See Saving Figures.
Common mistakes
- Overlapping labels. Use
layout="constrained". tight_layout()before adding a colorbar.axesindexing confusion: 2-D for grids, 1-D for a single row/column, bare Axes for 1×1. Usesqueeze=Falseoraxes.flat.- A shared colorbar across panels with different scales.
- Figure too small for its font sizes, so the journal shrinks it into illegibility.
- Adding panels with
fig.add_subplotaftersubplots: layout engines get confused. - Not closing figures in a loop.
See also
Figure and Axes · Saving Figures · Faceting · Matplotlib Artists · rcParams and Style Sheets
Exercise
Write mosaic_report(rows), where rows is the mosaic drawn as a list of
equal-length strings of single-character panel keys and . marks an empty
cell. Convert each string to a list of characters, build the layout with
plt.subplot_mosaic at 3.0 by 2.5 inches per cell, and give every panel a
bold label placed at (-0.1, 1.05) in ax.transAxes coordinates, which is
what keeps panel labels in the same place regardless of each panel's data
range.
Return grid=<nrow>x<ncol> panels=<n> labels=<n> first, where panels counts
the Axes the mosaic created and labels sums the text artists across them.
Then, for each key in alphabetical order, return
<key> rows=<start>-<stop> cols=<start>-<stop> cells=<n>, reading the spans
from ax.get_subplotspec(); rowspan and colspan are range objects, so
their start and stop bound the cells the panel occupies and their lengths
multiply to the cell count. Finish with filled=<n> empty=<n>. A . creates
no Axes, so a mosaic containing one must report a non-zero empty count and
one fewer key.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | AAB CCB DDD | grid=3x3 panels=4 labels=4 A rows=0-1 cols=0-2 cells=2 B rows=0-2 cols=2-3 cells=2 C rows=1-2 cols=0-2 cells=2 D rows=2-3 cols=0-3 cells=3 filled=9 empty=0 |
| 2 | stdin | AB AC | grid=2x2 panels=3 labels=3 A rows=0-2 cols=0-1 cells=2 B rows=0-1 cols=1-2 cells=1 C rows=1-2 cols=1-2 cells=1 filled=4 empty=0 |
| 3 | stdin | AA. BCC | grid=2x3 panels=3 labels=3 A rows=0-1 cols=0-2 cells=2 B rows=1-2 cols=0-1 cells=1 C rows=1-2 cols=1-3 cells=2 filled=5 empty=1 |
| 4 | stdin | A | grid=1x1 panels=1 labels=1 A rows=0-1 cols=0-1 cells=1 filled=1 empty=0 |
Hints · 3
01Hint
`plt.subplot_mosaic` accepts a list of lists of keys, which avoids depending on newline handling in the string form.
02Hint
`spec.rowspan` is a `range`; `len(range)` gives the number of rows the panel spans.
03Hint
Sort the keys before reporting — dictionary order is insertion order, not alphabetical.