Plotting10/14
Starting sandbox…
Beginner·4 graded tests

Subplots and Layout

Subplots and Layout

Core Principle: Multi-panel figure layouts in Matplotlib are constructed using plt.subplots for uniform grids, GridSpec for custom span ratios, or subplot_mosaic for 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:

[1]python
error1ms
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'
Figure 1

Readable, self-documenting, and far easier than the equivalent GridSpec. Use . for an empty cell.

[2]python
out1ms

This code completed without producing output.

Figure 1

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:

[3]python
out1ms

This code completed without producing output.

Figure 1

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

[4]python
error1ms
<cell>:2: UserWarning: The figure layout has changed to tight
Figure 1Figure 2

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:

[5]python
error1ms
<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

[ ]python
out1ms

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.
  • axes indexing confusion: 2-D for grids, 1-D for a single row/column, bare Axes for 1×1. Use squeeze=False or axes.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_subplot after subplots: 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.

exerciseEvery submission runs independently in a fresh namespace.

Test cases · 4

#viainputexpected stdout
1stdinAAB 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
2stdinAB 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
3stdinAA. 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
4stdinA 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.