Subplots and Layout
In one line:
plt.subplotsfor regular grids,GridSpecorsubplot_mosaicfor 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.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