pythonforbio.
[WASM idle]
Plotting10/14

Subplots and Layout

Starting sandbox…
Beginnerlesson

Subplots and Layout

In one line: plt.subplots for regular grids, GridSpec or subplot_mosaic for irregular ones, layout="constrained" to stop labels overlapping.

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:

fig, axd = plt.subplot_mosaic(
    """
    AAB
    CCB
    DDD
    """,
    figsize=(10, 8), layout="constrained",
)

axd["A"].plot(...)          # spans two columns of the top row
axd["B"].scatter(...)       # spans two rows on the right
axd["D"].imshow(...)        # full width at the bottom

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

fig, axd = plt.subplot_mosaic([["volcano", "volcano", "legend"],
                               ["heatmap", "pca",     "pca"]],
                              figsize=(12, 8), layout="constrained")

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:

from matplotlib.gridspec import GridSpec

fig = plt.figure(figsize=(10, 8))
gs = GridSpec(3, 3, figure=fig, height_ratios=[3, 1, 1], width_ratios=[2, 2, 1],
              hspace=0.3, wspace=0.2)

ax_main = fig.add_subplot(gs[0, :2])
ax_side = fig.add_subplot(gs[0, 2])
ax_bot  = fig.add_subplot(gs[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

fig, axes = plt.subplots(2, 2, layout="constrained")     # modern, recommended
fig.tight_layout()                                         # legacy
fig, axes = plt.subplots(2, 2, layout="compressed")        # for shared-axis grids

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:

fig.subplots_adjust(left=0.1, right=0.95, top=0.9, bottom=0.1,
                    hspace=0.3, wspace=0.25)

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

for label, ax in axd.items():
    ax.text(-0.1, 1.05, label, transform=ax.transAxes,
            fontsize=12, fontweight="bold", va="top")

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

lesson example

No output yet — run the code to populate this drawer.