26 min read

> "Complicated layouts are the test of whether you really understand matplotlib. If you can build a three-column asymmetric figure with shared axes, a main plot, and two marginal distributions, you have graduated."

Learning Objectives

  • Create multi-panel figures using plt.subplots(nrows, ncols) with shared axes
  • Apply GridSpec and fig.add_gridspec() to create layouts with unequal panel sizes and spanning panels
  • Use constrained_layout and tight_layout to manage spacing automatically
  • Implement shared axes (sharex, sharey) for aligned multi-panel comparisons
  • Create inset axes using ax.inset_axes() for zoom panels and detail views
  • Apply Chapter 8 design principles (alignment, visual hierarchy, reading order) through specific matplotlib code
  • Build complex layouts: hero-plus-supporting, main-with-marginal, asymmetric grids, nested subfigures

Chapter 13: Subplots, GridSpec, and Multi-Panel Figures

"Complicated layouts are the test of whether you really understand matplotlib. If you can build a three-column asymmetric figure with shared axes, a main plot, and two marginal distributions, you have graduated." — Approximately, many matplotlib tutorials


Chapter 10 introduced the canonical fig, ax = plt.subplots() pattern for single-chart figures. Chapter 11 showed you how to fill those single charts with the five essential chart types. Chapter 12 taught you how to polish a single chart into a publication-quality figure. Now we extend the pattern to multiple charts in one figure — the multi-panel layouts that Chapter 8 introduced conceptually as small multiples, dashboards, and hero-plus-supporting compositions.

The good news is that multi-panel figures are built from the same primitives you already know. Every panel is still an Axes. Every Axes method still works the same way. The only thing that changes is how you create and arrange the Axes in the first place. Instead of one Axes in one Figure, you have many Axes in one Figure, and you need a way to specify where each Axes goes.

matplotlib provides three main ways to specify multi-panel layouts: the simple grid (plt.subplots(nrows, ncols)), the flexible grid (fig.add_gridspec() with GridSpec), and the composable approach (fig.subfigures() for matplotlib 3.4+). Each serves a different purpose, and knowing when to reach for each one is part of what this chapter teaches.

The threshold concept of this chapter is that layout is code. Every design decision you sketched on paper for a multi-panel figure — panel sizes, alignment, spacing, which panels share axes — has a direct translation into matplotlib API calls. The act of translation is the skill you build in this chapter. Once you can look at a design sketch and write the GridSpec code that produces it, complex multi-panel figures become as approachable as single charts.

A warning before we start. matplotlib's layout engines are powerful but occasionally frustrating. You will have charts where titles overlap, where axis labels get cut off, where panels fight for space in ways that seem irrational. The fix is usually one of three things: switch from tight_layout to constrained_layout, adjust wspace and hspace parameters manually, or increase the figsize. Do not be discouraged by layout bugs — they are a normal part of matplotlib work, and they have predictable solutions.

This chapter builds on everything from Chapters 10-12. The fig/ax pattern is still the foundation. The essential chart types still populate the panels. The styling and typography still apply. Multi-panel figures just add a structural layer on top. Let us start with the simple case and build toward complexity.


13.1 The Simple Grid: plt.subplots(nrows, ncols)

The plt.subplots function, which you already know from earlier chapters, takes optional nrows and ncols arguments. With no arguments, it creates a single Axes. With nrows=N, ncols=M, it creates an N×M grid of Axes.

Basic 1D Grid

import matplotlib.pyplot as plt

# 1 row, 3 columns
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
# axes is a 1D array: axes[0], axes[1], axes[2]

axes[0].plot(x, y1)
axes[0].set_title("Left Panel")

axes[1].plot(x, y2)
axes[1].set_title("Middle Panel")

axes[2].plot(x, y3)
axes[2].set_title("Right Panel")

fig.savefig("three_panels.png", dpi=300, bbox_inches="tight")

With a single row or a single column, axes is a 1D numpy array. You index into it with a single integer: axes[0], axes[1], axes[2]. Each element is a full Axes object supporting every method you have learned.

Basic 2D Grid

# 2 rows, 3 columns
fig, axes = plt.subplots(2, 3, figsize=(15, 8))
# axes is a 2D array: axes[row, col]

axes[0, 0].plot(x, y_a)
axes[0, 1].plot(x, y_b)
axes[0, 2].plot(x, y_c)
axes[1, 0].plot(x, y_d)
axes[1, 1].plot(x, y_e)
axes[1, 2].plot(x, y_f)

With multiple rows and columns, axes is a 2D numpy array indexed as axes[row, col]. The top-left panel is axes[0, 0], top-right is axes[0, ncols-1], bottom-left is axes[nrows-1, 0], bottom-right is axes[nrows-1, ncols-1].

Iterating Over Axes

For small multiples, you usually want to iterate over the axes rather than indexing them manually. The axes.flat attribute or axes.flatten() method gives you a 1D view:

fig, axes = plt.subplots(3, 4, figsize=(16, 10))

for ax, (label, data) in zip(axes.flat, datasets.items()):
    ax.plot(data["x"], data["y"])
    ax.set_title(label, fontsize=10)

fig.savefig("small_multiple.png", dpi=300, bbox_inches="tight")

The zip(axes.flat, datasets.items()) pairs each Axes with one dataset. This is the standard pattern for building small multiples from a dictionary or DataFrame of grouped data. If there are more Axes than datasets (because the grid has extra slots), you can hide the leftover Axes with ax.set_visible(False):

for i in range(len(datasets), len(axes.flat)):
    axes.flat[i].set_visible(False)

figsize for Multi-Panel

The figsize parameter specifies the size of the whole Figure, not individual panels. For a multi-panel figure, you usually want to multiply the per-panel size by the grid dimensions:

per_panel = (4, 3)  # each panel is 4 inches wide, 3 inches tall
nrows, ncols = 3, 4
fig, axes = plt.subplots(nrows, ncols, figsize=(per_panel[0] * ncols, per_panel[1] * nrows))

This produces a 16×9 inch figure with 12 panels, each approximately 4×3 inches. Adjust based on whether you want larger or smaller individual panels.

Common Pitfalls with plt.subplots

1. Indexing a 1D grid as 2D. If you use plt.subplots(1, 3), axes is 1D. Accessing axes[0, 0] raises an error. Use axes[0] instead.

2. Assuming the return is always 2D. plt.subplots(N, M) returns a 2D array only when both N and M are ≥ 2. For plt.subplots(3, 1) or plt.subplots(1, 3), the return is a 1D array. For plt.subplots(1, 1), the return is a single Axes object (not an array). To get consistent behavior, use squeeze=False:

fig, axes = plt.subplots(1, 3, squeeze=False)
# axes is always 2D, so axes[0, 0] works even for a 1x3 grid

3. Forgetting that figsize is the whole Figure. If you write plt.subplots(3, 4, figsize=(4, 3)), you get a 3×4 grid crammed into a 4×3 inch figure, which is tiny. Scale figsize by the grid dimensions.

Check Your Understanding — Write the code to create a 2×2 grid with figsize (10, 8), then loop over the four panels and plot x**i for i in 1, 2, 3, 4 with titles "Linear", "Quadratic", "Cubic", "Quartic".


13.2 Shared Axes: sharex and sharey

One of the most common needs in multi-panel figures is shared axes — either all panels using the same x-axis range, the same y-axis range, or both. matplotlib supports this through the sharex and sharey parameters to plt.subplots.

Shared X-Axis

fig, axes = plt.subplots(3, 1, sharex=True, figsize=(10, 9))

axes[0].plot(years, temperature)
axes[0].set_ylabel("Temperature (°C)")

axes[1].plot(years, co2)
axes[1].set_ylabel("CO2 (ppm)")

axes[2].plot(years, sea_level)
axes[2].set_ylabel("Sea Level (mm)")
axes[2].set_xlabel("Year")

fig.suptitle("Three Measurements of a Warming Planet")

With sharex=True, all three panels use the same x-axis limits and tick positions. Zooming one panel's x-axis (in interactive mode) zooms all of them. Setting ax.set_xlim(...) on any panel affects all of them. The x-axis tick labels are drawn only on the bottom panel, which removes redundancy and creates a cleaner look.

This is exactly the three-panel climate figure Chapter 8 sketched. The shared x-axis lets the reader compare all three variables at the same year by looking at the same vertical column across panels.

Shared Y-Axis

fig, axes = plt.subplots(1, 4, sharey=True, figsize=(16, 4))

for ax, (decade, data) in zip(axes, decades.items()):
    ax.hist(data, bins=20)
    ax.set_title(f"{decade}s")

axes[0].set_ylabel("Frequency")

With sharey=True, all panels use the same y-axis range. The y-axis labels are drawn only on the leftmost panel, reducing clutter. This is useful when comparing distributions or magnitudes across subgroups where the y-axis values should be directly comparable.

Sharing Only Some Axes

Sometimes you want some panels to share and others not. The sharex parameter can also take a specific Axes reference:

fig, axes = plt.subplots(2, 2, figsize=(10, 8))

axes[0, 0].plot(x, y)
axes[0, 1].plot(x, y2)
axes[1, 0].plot(x, y3, sharex=axes[0, 0])  # share x with top-left
axes[1, 1].plot(x, y4, sharex=axes[0, 1])  # share x with top-right

For simple cases, sharex=True and sharex="col" (share within each column) or sharex="row" (share within each row) handle most needs. The explicit sharing with specific Axes is an escape hatch for unusual layouts.

When Shared Axes Fail

Shared axes are powerful but not always appropriate. Some cases to watch for:

1. Panels with wildly different ranges. If one panel has values from 0-10 and another from 0-10000, sharing the y-axis would compress the small-range data to a flat line at the bottom. Either do not share (use free axes) or use a log scale.

2. Panels with different quantities. A temperature panel (-2 to +2 °C) and a CO2 panel (280-420 ppm) should not share the y-axis because they measure different things. Share the x-axis (year) but let each panel have its own y-axis.

3. Small multiples that are meant to show shape. If the point is to compare the shape of the pattern in each panel rather than absolute values, free axes are often better. Chapter 8 discussed this trade-off.

The rule: share the axis when absolute comparison is the point; use free axes when shape comparison is the point. And label the chart clearly so the reader knows which kind of comparison is possible.


13.3 GridSpec: Flexible Layouts

plt.subplots(nrows, ncols) creates a regular grid — every panel is the same size, every column the same width, every row the same height. For most multi-panel figures this is fine. But some designs require unequal panel sizes (a large hero chart with smaller supporting panels), spanning panels (a wide panel across multiple columns), or asymmetric arrangements. For these, you use GridSpec.

Creating a GridSpec

import matplotlib.pyplot as plt

fig = plt.figure(figsize=(12, 8))
gs = fig.add_gridspec(nrows=3, ncols=3, hspace=0.3, wspace=0.3)

# Use slices to create Axes that span multiple grid cells
ax_main = fig.add_subplot(gs[0:2, 0:2])   # top-left 2x2 block
ax_top = fig.add_subplot(gs[0:2, 2])      # right column, top 2 rows
ax_bottom = fig.add_subplot(gs[2, :])     # entire bottom row

ax_main.plot(x, y, label="Main")
ax_top.plot(y, x)
ax_bottom.plot(years, trend)

fig.add_gridspec(nrows=3, ncols=3) creates a 3×3 grid specification. You then use fig.add_subplot(gs[row_slice, col_slice]) to create Axes that span specific regions of the grid. Slicing works like numpy — gs[0:2, 0:2] creates an Axes covering the top-left 2×2 block; gs[2, :] creates an Axes covering the entire bottom row.

The result is a layout that is impossible with plt.subplots: a large main panel, a narrow side panel, and a wide bottom panel. This is the hero-plus-supporting composition from Chapter 8.

Unequal Row and Column Sizes

By default, all rows and columns in a GridSpec are the same size. To make them different, pass width_ratios and height_ratios:

gs = fig.add_gridspec(
    nrows=2, ncols=2,
    width_ratios=[3, 1],    # first column is 3x wider than second
    height_ratios=[1, 2],   # second row is 2x taller than first
)

ax_main = fig.add_subplot(gs[1, 0])     # large bottom-left
ax_top = fig.add_subplot(gs[0, 0])      # top-left (smaller)
ax_side = fig.add_subplot(gs[:, 1])     # entire right column

width_ratios=[3, 1] means the columns have widths in ratio 3:1 — the first column is three times wider than the second. height_ratios=[1, 2] means the rows have heights in ratio 1:2 — the second row is twice as tall as the first.

Scatter with Marginal Distributions

A classic use of GridSpec is the scatter plot with marginal distributions — a central scatter plus a histogram on the top showing the x-distribution and a histogram on the right showing the y-distribution:

fig = plt.figure(figsize=(8, 8))
gs = fig.add_gridspec(
    nrows=2, ncols=2,
    width_ratios=[4, 1],
    height_ratios=[1, 4],
    hspace=0.05, wspace=0.05,
)

ax_scatter = fig.add_subplot(gs[1, 0])
ax_histx = fig.add_subplot(gs[0, 0], sharex=ax_scatter)
ax_histy = fig.add_subplot(gs[1, 1], sharey=ax_scatter)

ax_scatter.scatter(x, y, alpha=0.5)
ax_histx.hist(x, bins=30)
ax_histy.hist(y, bins=30, orientation="horizontal")

# Hide tick labels on the marginals (they share with the scatter)
ax_histx.tick_params(axis="x", labelbottom=False)
ax_histy.tick_params(axis="y", labelleft=False)

The sharex=ax_scatter on ax_histx means the top histogram shares its x-axis with the main scatter. Similarly for the right histogram. The shared axes ensure the histograms align exactly with the data on the scatter. This pattern appears in statistical software (R's ggpairs, seaborn's jointplot) and is one of the most common uses of GridSpec.

The gridspec_kw Shortcut

For simpler GridSpec needs, you can pass gridspec_kw to plt.subplots:

fig, axes = plt.subplots(
    2, 2,
    figsize=(10, 8),
    gridspec_kw={"width_ratios": [3, 1], "height_ratios": [1, 2]},
)

This is less flexible than the explicit GridSpec approach (you still get a regular grid, just with unequal row and column sizes), but it is simpler to write when you do not need spanning panels.


13.4 Layout Management: constrained_layout and tight_layout

Multi-panel figures often have spacing problems. Titles overlap with the panels above. Axis labels get cut off. Tick labels collide between adjacent panels. These are not bugs in your chart code — they are layout issues, and matplotlib provides two tools for solving them.

constrained_layout: The Modern Answer

fig, axes = plt.subplots(2, 3, figsize=(12, 8), constrained_layout=True)

Setting constrained_layout=True when you create the figure tells matplotlib to use a constraint-based layout solver that automatically adjusts spacing to avoid collisions. Titles, axis labels, tick labels, and legends all get enough space. Panels are positioned to look balanced.

constrained_layout is the modern recommendation for multi-panel figures. It handles most layout problems automatically, and it works well with legends, colorbars, and inset axes.

tight_layout: The Legacy Approach

Before constrained_layout, matplotlib used tight_layout — a heuristic that adjusted subplot spacing after the figure was drawn:

fig, axes = plt.subplots(2, 3, figsize=(12, 8))
# ... plot stuff ...
fig.tight_layout()  # call before savefig

tight_layout still works but is less robust than constrained_layout. It can produce unexpected results with complex layouts, colorbars, or suptitles. For new code, prefer constrained_layout. For existing code that uses tight_layout, you can leave it alone — it will continue to work.

Manual Adjustment with subplots_adjust

When automatic layout managers do not produce the exact spacing you want, you can adjust manually:

fig.subplots_adjust(
    left=0.1,    # fraction of figure width for left margin
    right=0.95,  # fraction of figure width for right margin
    bottom=0.1,  # fraction of figure height for bottom margin
    top=0.95,    # fraction of figure height for top margin
    wspace=0.3,  # width space between subplots (fraction of average axes width)
    hspace=0.4,  # height space between subplots (fraction of average axes height)
)

The values are fractions of the figure size. wspace=0.3 means the horizontal space between subplots is 30% of the average subplot width. Increasing wspace makes the panels farther apart; decreasing it brings them closer.

The Layout Decision

For most work:

  • Single-panel figures: no layout manager needed; bbox_inches="tight" in savefig is sufficient.
  • Multi-panel figures with automatic needs: use constrained_layout=True when creating the figure.
  • Multi-panel figures that need specific spacing: use constrained_layout=True as a baseline and adjust with subplots_adjust only if necessary.
  • Legacy code: tight_layout still works but is being superseded by constrained_layout.

Setting constrained_layout=True as a default in your rcParams is a reasonable choice:

mpl.rcParams["figure.constrained_layout.use"] = True

This way, every figure you create has constrained_layout by default, and you never have to remember to set it explicitly.


13.5 Inset Axes: Zoom and Detail Panels

Sometimes you want a small panel inside a larger panel — a "zoom" of a specific region, a minimap showing geographic context, or a legend area that is physically part of the plot area. matplotlib supports this through inset axes.

Creating an Inset with ax.inset_axes

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(years, temperature)
ax.set_title("Global Temperature, 1880-2024")
ax.set_xlabel("Year")
ax.set_ylabel("Anomaly (°C)")

# Add an inset showing the recent decade in detail
ax_inset = ax.inset_axes([0.05, 0.55, 0.35, 0.35])  # [x, y, width, height] in axes-fraction coords
ax_inset.plot(years[-15:], temperature[-15:])
ax_inset.set_title("2010-2024", fontsize=9)
ax_inset.tick_params(labelsize=8)

The ax.inset_axes([x, y, width, height]) method creates a new Axes positioned inside the parent Axes. The coordinates are in axes-fraction space: [0.05, 0.55, 0.35, 0.35] means "positioned at 5% from the left, 55% from the bottom, 35% wide, 35% tall, all relative to the parent Axes."

The inset is a full Axes. You can call any plot method on it. It has its own axis labels, its own title, its own limits. It can show a zoomed version of the parent data, or completely different data, or anything you want.

Indicating the Zoom Region

When an inset shows a zoomed region of the parent chart, it is helpful to draw a rectangle on the parent showing what the inset is zooming into:

# Mark the zoomed region on the parent chart
ax.indicate_inset_zoom(ax_inset, edgecolor="gray")

indicate_inset_zoom draws a rectangle on the parent at the x/y limits of the inset and connects it to the inset with lines. This makes the zoom relationship explicit.

Inset Placement

The [x, y, width, height] coordinates for inset placement are in axes-relative coordinates by default. Common positions:

  • Top-left: [0.05, 0.55, 0.35, 0.35]
  • Top-right: [0.60, 0.55, 0.35, 0.35]
  • Bottom-left: [0.05, 0.05, 0.35, 0.35]
  • Bottom-right: [0.60, 0.05, 0.35, 0.35]

Adjust to avoid overlapping with the data or with important annotations. For positioning relative to the data rather than relative to the axes, use transform=ax.transData in the inset_axes call.


13.6 Subfigures: Composing Figures from Logical Groups

matplotlib 3.4 (released April 2021) introduced subfigures — a way to compose a figure from logical groups, each of which can have its own layout, title, and styling. This is useful for complex compositions where a single GridSpec becomes unwieldy.

Basic Subfigures

fig = plt.figure(figsize=(14, 8), constrained_layout=True)
subfigs = fig.subfigures(1, 2, width_ratios=[2, 1])

# Left subfigure: a 2x2 grid of scatter plots
axes_left = subfigs[0].subplots(2, 2)
subfigs[0].suptitle("Scatter Plots", fontsize=14)
# ... plot on axes_left ...

# Right subfigure: a single panel showing a time series
ax_right = subfigs[1].subplots(1, 1)
subfigs[1].suptitle("Time Series", fontsize=14)
ax_right.plot(years, values)

fig.subfigures(1, 2) divides the main figure into a 1×2 grid of subfigures. Each subfigure is like a mini Figure with its own subplots, suptitle, and other methods. The result is a composition where the left subfigure has its own 2×2 grid and the right subfigure has a single panel — but they are all rendered as one figure with coherent spacing.

Subfigures are useful when:

  • You have multiple logical groups of panels with different internal structures.
  • Each group needs its own title or subtitle.
  • You want GridSpec-level control but cleaner code than a single huge GridSpec.

For most simpler cases, plt.subplots or a single GridSpec is sufficient and easier. Subfigures are an advanced tool for when those simpler approaches become awkward.


13.7 Saving Multi-Panel Figures

Multi-panel figures are saved the same way as single-panel figures, but there are a few specific considerations.

Use bbox_inches="tight"

Multi-panel figures often have text (axis labels, legends, titles) extending beyond the plotting areas. Without bbox_inches="tight", the saved image may crop off this external text. Always use it:

fig.savefig("multipanel.png", dpi=300, bbox_inches="tight")

Match DPI to the Number of Panels

A 3×4 multi-panel figure at figsize=(16, 12) and dpi=300 produces a 4800×3600 pixel image, which is 17 megapixels. That is fine for print but excessive for web display. For web, consider dpi=150 to produce a smaller file. For slide decks, dpi=200 is usually sufficient.

Consider SVG for Vector Multi-Panel

Vector formats (SVG, PDF) scale without quality loss and are usually smaller for multi-panel figures than raster formats. If your target is print or a viewer that supports vector, prefer .svg or .pdf:

fig.savefig("multipanel.svg", bbox_inches="tight")
fig.savefig("multipanel.pdf", bbox_inches="tight")

The only downside is that some presentation tools (PowerPoint, Keynote) handle PDF poorly. Test the format in your actual presentation tool before committing.

One File per Version

For a report with multiple multi-panel figures, save each one to a separate file with a descriptive name:

fig.savefig("climate_3panel_temperature.png", dpi=300, bbox_inches="tight")
fig.savefig("climate_dashboard_quarterly.png", dpi=300, bbox_inches="tight")

Descriptive filenames make it easier to find the right figure later, and they make the report author's life easier when a figure needs to be updated.

13.8 Secondary Axes: Two Y-Scales on One Panel

Chapter 4 warned strongly against dual-axis charts because they manufacture apparent correlations by forcing two unrelated variables onto the same spatial frame. That warning still applies. But matplotlib supports dual-axis charts through ax.twinx() (twin x-axis, two y-scales) and ax.twiny() (twin y-axis, two x-scales), and there are narrow cases where they are legitimate — specifically, when the two axes are unit conversions of the same variable.

The Legitimate Case: Unit Conversions

fig, ax_c = plt.subplots(figsize=(10, 4))
ax_f = ax_c.twinx()  # twin x, second y-axis

ax_c.plot(years, temperature_celsius, color="#d62728")
ax_c.set_ylabel("Temperature (°C)", color="#d62728")
ax_c.tick_params(axis="y", colors="#d62728")

# The right axis shows the same data in Fahrenheit
ax_f.set_ylim(c_to_f(ax_c.get_ylim()[0]), c_to_f(ax_c.get_ylim()[1]))
ax_f.set_ylabel("Temperature (°F)", color="gray")
ax_f.tick_params(axis="y", colors="gray")

def c_to_f(c):
    return c * 9/5 + 32

The right y-axis is not a separate variable — it is the same temperature data in Fahrenheit. The reader looks at the left axis to read Celsius, at the right to read Fahrenheit. There is no false correlation risk because there is only one variable. This is the narrow case where dual-axis is honest.

What Not to Do

Do not use dual-axis to show two unrelated variables on the same chart. A line of revenue in USD on the left and a line of customer count on the right is a dual-axis chart that implies (visually) that the two series are correlated, regardless of whether they are. Use small multiples instead (Chapter 8).

If you must use dual-axis for two different variables (because a stakeholder insists or a publication requires it), be explicit in the chart title and caption that the reader should not interpret visual alignment as correlation. Even better: do not use dual-axis.

13.8 The Design-to-Code Translation Exercise

The threshold concept says layout is code. The best way to build the skill is to practice translating design sketches into GridSpec calls. This section walks through three design sketches and their matplotlib implementations, showing the mental process at each step.

Sketch 1: Three Stacked Time Series

The sketch: Three panels stacked vertically. Each panel is a line chart. All three share the same time axis at the bottom. The top panel is labeled "Temperature," the middle "CO2," and the bottom "Sea Level." The figure is wider than it is tall.

The decomposition: - 3 rows, 1 column. → nrows=3, ncols=1. - All panels share the x-axis. → sharex=True. - Wider than tall. → figsize=(12, 9) or similar aspect ratio. - Equal heights. → no height_ratios argument needed.

The code:

fig, axes = plt.subplots(3, 1, figsize=(12, 9), sharex=True, constrained_layout=True)
# plot on axes[0], axes[1], axes[2]

Why this works: The sketch has a regular grid structure (no spanning, no unequal sizes), so plt.subplots is sufficient. Shared x-axis is the one customization, and it has a direct parameter.

Sketch 2: Scatter with Marginals

The sketch: A central scatter plot with a histogram above it (showing the x-distribution) and another histogram to its right (showing the y-distribution). The central scatter is larger; the histograms are narrow.

The decomposition: - 2 rows, 2 columns. → nrows=2, ncols=2. - Unequal sizes: the scatter (bottom-left) is large; the histograms are narrow. → width_ratios=[4, 1], height_ratios=[1, 4]. - The scatter's x-axis should align with the top histogram's x-axis. → sharex. - The scatter's y-axis should align with the right histogram's y-axis. → sharey. - The top-right corner is unused. → create only three of the four possible cells.

The code:

fig = plt.figure(figsize=(8, 8), constrained_layout=True)
gs = fig.add_gridspec(2, 2, width_ratios=[4, 1], height_ratios=[1, 4], wspace=0.05, hspace=0.05)

ax_scatter = fig.add_subplot(gs[1, 0])
ax_histx = fig.add_subplot(gs[0, 0], sharex=ax_scatter)
ax_histy = fig.add_subplot(gs[1, 1], sharey=ax_scatter)

Why this works: Unequal panel sizes require GridSpec (not plt.subplots). The shared-axis relationship between the scatter and each histogram is specified explicitly. The top-right corner is simply not created, leaving empty space.

Sketch 3: Hero Plus Four Supporting Panels

The sketch: A large main chart on the top (spanning the full width), four smaller panels below it in a row. The main chart is twice as tall as the supporting row.

The decomposition: - Rows: the top row (hero) is taller than the bottom row (supporting). → nrows=2, height_ratios=[2, 1]. - Columns: the bottom row has 4 panels. The top row needs 1 panel that spans all 4 columns. → ncols=4. - The top Axes spans all 4 columns. → slice notation: gs[0, :]. - The bottom row has 4 independent panels. → gs[1, 0], gs[1, 1], etc.

The code:

fig = plt.figure(figsize=(14, 8), constrained_layout=True)
gs = fig.add_gridspec(2, 4, height_ratios=[2, 1])

ax_hero = fig.add_subplot(gs[0, :])
ax_a = fig.add_subplot(gs[1, 0])
ax_b = fig.add_subplot(gs[1, 1])
ax_c = fig.add_subplot(gs[1, 2])
ax_d = fig.add_subplot(gs[1, 3])

Why this works: The spanning top panel requires GridSpec slicing (gs[0, :]). The four bottom panels are separate cells. The height ratios make the hero twice as tall as the supporting row.

The Pattern

Every GridSpec design follows the same pattern:

  1. Count rows and columns. What is the basic grid structure?
  2. Identify unequal sizes. Do some rows or columns need different sizes? If yes, set width_ratios or height_ratios.
  3. Identify spanning cells. Does any panel cover multiple cells? If yes, use slice notation (gs[0:2, 0:2], gs[0, :], etc.).
  4. Identify shared axes. Do any panels share x or y axes? If yes, set sharex or sharey parameters or use the parameter on fig.add_subplot.
  5. Set reasonable figsize. The whole figure should be at least 4×3 inches per panel and adjusted for aspect.

Once you can do this decomposition automatically, the GridSpec code writes itself. Practice by looking at any published multi-panel figure (from the NYT, FT, or a scientific paper) and writing the code that would produce a layout with the same structure.

13.8 Common Layout Pitfalls and How to Fix Them

Multi-panel layouts are where most matplotlib bugs live. This section catalogs the most common problems and their solutions.

Pitfall 1: Titles Overlapping the Panels Above

Symptom: A panel's title is drawn on top of the panel above it, or a figure suptitle overlaps with the top row of panels.

Cause: The layout manager has not allocated enough vertical space between panels, or the suptitle is positioned too close to the axes.

Fix:

# Use constrained_layout instead of tight_layout
fig, axes = plt.subplots(3, 1, constrained_layout=True)

# Or increase hspace manually
fig.subplots_adjust(hspace=0.4)

# Or for suptitle, reserve more space at the top
fig.suptitle("Main Title", y=1.02)  # positioned slightly above the default

Pitfall 2: Axis Labels Cut Off at the Edges

Symptom: The bottom x-axis label or the left y-axis label is cropped out of the saved image.

Cause: The figure margin is too small for the label. Default savefig does not always account for text outside the plotting area.

Fix:

# Use bbox_inches="tight" when saving
fig.savefig("chart.png", dpi=300, bbox_inches="tight")

# Or use constrained_layout, which reserves margin for labels automatically
fig, axes = plt.subplots(1, 1, constrained_layout=True)

Pitfall 3: Tick Labels Colliding Between Adjacent Panels

Symptom: In a row of panels with sharey=False, the y-axis tick labels on one panel overlap the plotting area of the next panel.

Cause: The tick labels are wider than the default spacing between panels, and matplotlib does not automatically account for this.

Fix:

fig, axes = plt.subplots(1, 3, figsize=(15, 4))

# Option 1: use constrained_layout (recommended)
# (recreate with constrained_layout=True)

# Option 2: increase wspace
fig.subplots_adjust(wspace=0.4)

# Option 3: share the y-axis if the scales are comparable
fig, axes = plt.subplots(1, 3, sharey=True)

Pitfall 4: The Wrong Axes Getting the Wrong Data

Symptom: You expected data to appear on axes[0, 1] but it ended up on axes[0, 0].

Cause: Indexing confusion — axes[row, col] is (row_index, column_index), which is not the same as (x_coordinate, y_coordinate).

Fix: Be explicit about which panel you are addressing. For a 2×3 grid:

  • axes[0, 0] is top-left
  • axes[0, 2] is top-right
  • axes[1, 0] is bottom-left
  • axes[1, 2] is bottom-right

If you use axes.flat with zip, the order of iteration is row-by-row (top-left → top-right → next row's first → next row's last).

Pitfall 5: Legend Appearing on Every Panel When You Wanted One

Symptom: You called ax.legend() in a loop over every panel, and now every panel has its own legend showing the same information.

Cause: Each ax.legend() call creates a legend on that specific Axes. In a small multiple where all panels have the same encoding, this is redundant.

Fix: Create the legend once at the figure level using the pattern from Section 13.8:

for ax in axes.flat:
    ax.plot(x, y1, color="#1f77b4")
    ax.plot(x, y2, color="#ff7f0e")

# Single legend at the figure level
handles = [Line2D([0], [0], color="#1f77b4", label="A"),
           Line2D([0], [0], color="#ff7f0e", label="B")]
fig.legend(handles=handles, loc="upper center", ncol=2, bbox_to_anchor=(0.5, 1.02))

Pitfall 6: Shared Axes Losing Tick Labels You Wanted

Symptom: You set sharex=True on a grid of panels, and the x-axis tick labels disappeared from all panels except the bottom row.

Cause: sharex=True automatically hides redundant tick labels on shared axes. This is usually correct (it reduces clutter), but sometimes you want the labels on every panel.

Fix:

# Re-enable tick labels on a specific Axes
axes[0, 0].tick_params(labelbottom=True)

Or, if you want labels on all panels, use sharex="col" (which shares within columns, so each column's bottom panel keeps labels) or do not share at all.

Pitfall 7: constrained_layout Warnings

Symptom: matplotlib prints a warning like "constrained_layout not applied because axes sizes collapsed" when you save the figure.

Cause: The figure is too small for the content after constrained_layout tries to adjust spacing. Some panels ended up with zero or negative size.

Fix: Increase the figure size. This is almost always the correct response:

fig, axes = plt.subplots(3, 4, figsize=(16, 12), constrained_layout=True)
# (bigger than the default, enough room for all panels)

Layout problems are usually solvable with constrained_layout=True, a larger figsize, or a manual subplots_adjust. Do not be discouraged by layout bugs — they are a normal part of matplotlib work, and they have predictable solutions.

13.8 Nested GridSpecs and Complex Layouts

Sometimes a single GridSpec is not enough. Complex layouts benefit from nesting one GridSpec inside another, so you can combine different grid structures in different regions of the figure.

The GridSpecFromSubplotSpec Pattern

import matplotlib.gridspec as gridspec

fig = plt.figure(figsize=(12, 8), constrained_layout=True)

# Outer grid: 2 rows, 1 column
outer = fig.add_gridspec(2, 1, height_ratios=[1, 2])

# Top: a single wide panel
ax_top = fig.add_subplot(outer[0])

# Bottom: a nested 1×3 GridSpec for three side-by-side panels
inner = gridspec.GridSpecFromSubplotSpec(
    1, 3,
    subplot_spec=outer[1],
    wspace=0.3,
)

ax_a = fig.add_subplot(inner[0, 0])
ax_b = fig.add_subplot(inner[0, 1])
ax_c = fig.add_subplot(inner[0, 2])

GridSpecFromSubplotSpec takes an existing SubplotSpec (a cell or region within a parent GridSpec) and divides it into its own sub-grid. The outer GridSpec has two rows; the second row is then subdivided into three columns. The result is a layout with a wide top panel and three smaller panels below it — a composition that a single GridSpec can also produce but that nested GridSpecs express more clearly when the structure is hierarchical.

The subfigures approach from Section 13.6 is the modern alternative for this kind of nested structure. Use GridSpecFromSubplotSpec when you need the fine-grained control of GridSpec within the nested region; use subfigures when you prefer a cleaner API and do not need GridSpec's specific features.

A Real Dashboard-Style Layout

Here is a more ambitious example — a dashboard layout with a main KPI chart, a row of supporting metrics, a side table, and a bottom breakdown:

fig = plt.figure(figsize=(14, 9), constrained_layout=True)
gs = fig.add_gridspec(
    nrows=3, ncols=4,
    height_ratios=[2, 1, 2],
    width_ratios=[1, 1, 1, 1],
    hspace=0.35, wspace=0.3,
)

# Top row: hero KPI chart spanning 3 columns, with a side panel in column 4
ax_hero = fig.add_subplot(gs[0, 0:3])
ax_side = fig.add_subplot(gs[0, 3])

# Middle row: four small-multiple metric panels
ax_m1 = fig.add_subplot(gs[1, 0])
ax_m2 = fig.add_subplot(gs[1, 1])
ax_m3 = fig.add_subplot(gs[1, 2])
ax_m4 = fig.add_subplot(gs[1, 3])

# Bottom row: two wide panels
ax_bottom_l = fig.add_subplot(gs[2, 0:2])
ax_bottom_r = fig.add_subplot(gs[2, 2:4])

# Plot data on each panel
ax_hero.plot(quarters, revenue)
ax_hero.set_title("Quarterly Revenue (Hero)")

ax_side.bar(products, totals)
ax_side.set_title("Top Products")

for ax, metric in zip([ax_m1, ax_m2, ax_m3, ax_m4], ["Users", "Churn", "NPS", "CAC"]):
    ax.plot(months, metrics[metric])
    ax.set_title(metric, fontsize=10)

ax_bottom_l.plot(months, regional_a)
ax_bottom_l.set_title("Region A")
ax_bottom_r.plot(months, regional_b)
ax_bottom_r.set_title("Region B")

fig.suptitle("Quarterly Business Review", fontsize=18, fontweight="semibold")

This single figure uses a 3×4 GridSpec with differentiated height ratios. The hero KPI chart spans three columns of the top row (height ratio 2). The four metric panels occupy the middle row (height ratio 1, smaller). The two wide bottom panels each span two columns of the bottom row (height ratio 2). The side panel is a single cell in the top-right.

Layouts like this look complicated at first glance but decompose into GridSpec primitives: decide the grid dimensions, decide the row and column ratios, create the Axes by slicing cells. Once you see the pattern, you can translate any sketched dashboard layout into GridSpec code.

13.8 Colorbars and Legends in Multi-Panel Figures

Multi-panel figures add complications to colorbars and legends because you have to decide which panel they belong to — or whether they span multiple panels.

Colorbar for a Single Panel

For a colorbar attached to a specific panel, pass the Axes reference to fig.colorbar:

fig, axes = plt.subplots(1, 3, figsize=(14, 4), constrained_layout=True)

for ax, data in zip(axes, [data_a, data_b, data_c]):
    im = ax.imshow(data, cmap="viridis")
    fig.colorbar(im, ax=ax, shrink=0.8)

This creates a separate colorbar for each panel, each showing the range of that panel's data. The shrink=0.8 parameter makes the colorbar slightly shorter than the full panel height for visual balance.

Shared Colorbar

When multiple panels share the same color scale, a single shared colorbar is more appropriate than one per panel:

fig, axes = plt.subplots(1, 3, figsize=(14, 4), constrained_layout=True)

# All three panels use the same vmin/vmax
vmin, vmax = 0, 100
for ax, data in zip(axes, [data_a, data_b, data_c]):
    im = ax.imshow(data, cmap="viridis", vmin=vmin, vmax=vmax)

# Create one colorbar for all three
fig.colorbar(im, ax=axes, shrink=0.8, label="Value")

Passing a list of Axes to fig.colorbar(ax=axes, ...) creates a colorbar that visually attaches to all three panels. The colorbar is placed to the right of the panels by default. Because all three imshow calls use the same vmin and vmax, the single colorbar correctly represents all three panels.

Figure-Level Legend

A single legend for all panels can be placed at the figure level:

fig, axes = plt.subplots(2, 2, figsize=(10, 8), constrained_layout=True)

for ax in axes.flat:
    ax.plot(x, y1, label="Series A", color="#1f77b4")
    ax.plot(x, y2, label="Series B", color="#ff7f0e")

# Create one legend for the whole figure
handles, labels = axes[0, 0].get_legend_handles_labels()
fig.legend(handles, labels, loc="upper center", ncol=2, bbox_to_anchor=(0.5, 1.02))

Pulling handles and labels from one representative Axes and passing them to fig.legend creates a single legend that visually belongs to the whole figure. The bbox_to_anchor=(0.5, 1.02) places the legend just above the figure. With constrained_layout=True, matplotlib automatically makes room for the legend.

This is much cleaner than repeating the same legend on every panel, and it is the canonical pattern when every panel shares the same series encoding.

13.9 The Climate Figure with Multiple Approaches

The progressive project for this chapter is the three-panel climate figure from Chapter 8. Here are three versions of it using different matplotlib APIs.

Version 1: plt.subplots with sharex

fig, axes = plt.subplots(
    3, 1,
    figsize=(12, 9),
    sharex=True,
    constrained_layout=True,
)

axes[0].plot(climate["year"], climate["temperature"], color="#d62728", linewidth=1.5)
axes[0].axhline(0, color="gray", linewidth=0.5, linestyle="--")
axes[0].set_ylabel("Temperature Anomaly (°C)")
axes[0].set_title("Temperature")

axes[1].plot(climate["year"], climate["co2"], color="#7f7f7f", linewidth=1.5)
axes[1].set_ylabel("CO2 (ppm)")
axes[1].set_title("CO2 Concentration")

axes[2].plot(climate["year"], climate["sea_level"], color="#1f77b4", linewidth=1.5)
axes[2].set_ylabel("Sea Level (mm)")
axes[2].set_title("Sea Level")
axes[2].set_xlabel("Year")

# Figure-level title
fig.suptitle("Three Measurements of a Warming Planet, 1880-2024", fontsize=16, fontweight="semibold")

# Declutter each panel
for ax in axes:
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)

fig.savefig("climate_3panel.png", dpi=300, bbox_inches="tight")

This is the simplest version. plt.subplots(3, 1, sharex=True) creates three stacked panels sharing the x-axis. Each panel gets its own plot and title. fig.suptitle adds an overall title. A loop applies the declutter to every panel.

Version 2: GridSpec with Unequal Heights

If you want the temperature panel to be more prominent (because it is the hero), use GridSpec with explicit height_ratios:

fig = plt.figure(figsize=(12, 10), constrained_layout=True)
gs = fig.add_gridspec(nrows=3, ncols=1, height_ratios=[2, 1, 1], hspace=0.3)

ax_temp = fig.add_subplot(gs[0, 0])
ax_co2 = fig.add_subplot(gs[1, 0], sharex=ax_temp)
ax_sea = fig.add_subplot(gs[2, 0], sharex=ax_temp)

# ... plotting ...

The height_ratios=[2, 1, 1] makes the temperature panel twice as tall as the CO2 and sea level panels, giving it visual hierarchy as the hero. This is the hero-plus-supporting composition from Chapter 8.

Version 3: 2×2 with One Large Panel and Supporting

For a more elaborate composition with a large scatter plot and two supporting panels:

fig = plt.figure(figsize=(12, 9), constrained_layout=True)
gs = fig.add_gridspec(nrows=2, ncols=2, width_ratios=[2, 1], height_ratios=[1, 1])

# Main scatter: CO2 vs temperature
ax_main = fig.add_subplot(gs[:, 0])
ax_main.scatter(climate["co2"], climate["temperature"], c=climate["year"], cmap="viridis")
ax_main.set_xlabel("CO2 (ppm)")
ax_main.set_ylabel("Temperature Anomaly (°C)")
ax_main.set_title("CO2 vs Temperature")

# Top right: CO2 time series
ax_co2 = fig.add_subplot(gs[0, 1])
ax_co2.plot(climate["year"], climate["co2"])
ax_co2.set_title("CO2 Over Time", fontsize=10)

# Bottom right: temperature time series
ax_temp = fig.add_subplot(gs[1, 1])
ax_temp.plot(climate["year"], climate["temperature"])
ax_temp.set_title("Temperature Over Time", fontsize=10)

fig.suptitle("Climate Relationships", fontsize=16)

gs[:, 0] creates an Axes spanning both rows of the first column (the hero scatter). gs[0, 1] and gs[1, 1] create the two supporting panels in the second column. The result is a three-panel composition where one panel is physically larger than the other two, matching the hero-plus-supporting pattern.

Three versions of the same story, each using a different matplotlib API. You would choose based on the composition you want: equal panels for a small multiple, unequal heights for hierarchy, asymmetric grid for hero-plus-supporting.


Chapter Summary

This chapter covered matplotlib's tools for building multi-panel figures: plt.subplots(nrows, ncols) for regular grids, GridSpec for flexible layouts with unequal sizes and spanning panels, constrained_layout=True for automatic spacing, inset_axes for panels inside panels, and subfigures for composing logical groups.

The threshold concept: layout is code. Every design decision you sketched on paper maps directly to matplotlib API calls. plt.subplots(3, 1, sharex=True) produces three stacked panels with a shared x-axis. fig.add_gridspec(2, 2, width_ratios=[3, 1]) produces a 2×2 grid where the first column is three times wider than the second. fig.add_subplot(gs[0:2, 0:2]) produces an Axes spanning the top-left 2×2 block. Learning matplotlib layouts is learning the mapping from design decisions to API calls.

constrained_layout=True is the modern default for multi-panel figures. It handles spacing automatically and avoids most collision problems. Use it as the baseline, and only reach for subplots_adjust when you need specific spacing.

Shared axes (sharex, sharey) are essential for small multiples where comparison requires consistent scales. For panels with different units (temperature in °C, CO2 in ppm), share only the x-axis and let each panel have its own y-axis.

Inset axes (ax.inset_axes) create a panel inside a panel, useful for zoom views and minimaps. indicate_inset_zoom draws a rectangle on the parent showing the zoom region.

The three climate figure variants in Section 13.7 demonstrate the range of compositions possible: a simple shared-x stack, a hero-plus-supporting stack with unequal heights, and a complex asymmetric grid with a main panel and two supporting panels. The same underlying data, rendered in three different compositions.

Next in Chapter 14: specialized chart types — heatmaps, contours, polar plots, and error bars. These expand the Chapter 11 vocabulary with chart types for specific kinds of data.


Spaced Review: Concepts from Chapters 1-12

  1. Chapter 8: The four jobs of composition are enabling comparison, establishing hierarchy, guiding reading order, and creating visual unity. Which matplotlib layout tool helps with each?

  2. Chapter 8: Small multiples depend on consistent encoding across panels. How do sharex and sharey implement this consistency?

  3. Chapter 8: The Z-pattern reading order suggests placing the hero in the top-left. Which GridSpec slicing places an Axes in the top-left?

  4. Chapter 10: matplotlib's object-oriented API uses explicit Figure and Axes references. How does this extend to multi-panel figures? What does axes[0, 1] refer to?

  5. Chapter 11: Each panel in a multi-panel figure can use any Axes method. If your top panel is a line chart and your bottom panel is a scatter plot, what two methods do you call on the respective Axes?

  6. Chapter 12: Customization applies to each panel individually. If you have a style function apply_clean_style(ax), how do you apply it to every panel in a multi-panel figure?