2025, Oct 16 02:00

Place a shared legend that spans multiple Matplotlib subplots and survives constrained layout and bbox_inches tight

Learn a robust way to place a shared legend across Matplotlib subplots. Fix misalignment caused by constrained layout and bbox_inches tight using a dedicated legend axes.

Placing a single shared legend for multiple subplots so that it spans exactly the width of the plot areas sounds straightforward, until the figure layout starts to move under your feet. If you rely on a figure-relative bbox for the legend, Matplotlib’s layout engines can shift axes and even resize the figure during save, and the legend won’t end up where you intended.

What we want and why it breaks

The goal is a shared legend, centered above two subplots, whose width matches the combined widths of the subplot drawing areas. A figure-level legend with a carefully computed bbox_to_anchor looks like it should work. But with layout="constrained" enabled and bbox_inches="tight" used in savefig, Matplotlib adjusts axes positions and the figure size at save time. The legend, anchored via figure coordinates, ends up misaligned and too wide.

Minimal example that demonstrates the issue

The snippet below computes a bbox spanning the width of the subplots and uses it for a figure-level legend. It also saves multiple sizes while keeping aspect ratio.

import matplotlib.pyplot as plt

canvas, ax_grid = plt.subplots(nrows=1, ncols=2, figsize=(7, 4), layout="constrained")

# sample data
xs = [1, 2, 3, 4, 5]
ys_a = [1, 2, 3, 4, 5]
ys_b = [5, 4, 3, 2, 1]
ax_grid[0].plot(xs, ys_a, label="Line 1")
ax_grid[1].plot(xs, ys_a, label="Line 1")
ax_grid[1].plot(xs, ys_b, label="Line 2")


def compute_span_bbox(ax_list):
    # Figure-coordinates bbox spanning the combined subplot width
    rects = [ax.get_position() for ax in ax_list]
    if rects:
        left = min(r.xmin for r in rects)
        right = max(r.xmax for r in rects)
        return (left, 0.0, right - left, 1.0)
    return None

# gather unique handles/labels across axes
all_handles, all_labels = [], []
for ax in ax_grid:
    hs, lbs = ax.get_legend_handles_labels()
    for h, lb in zip(hs, lbs):
        if lb not in all_labels:
            all_handles.append(h)
            all_labels.append(lb)

# figure-level legend with bbox spanning subplot width
canvas.legend(
    all_handles,
    all_labels,
    loc="outside upper center",
    ncol=2,
    bbox_to_anchor=compute_span_bbox(ax_grid),
    mode="expand",
    borderaxespad=-1,
    columnspacing=1.0,
    handletextpad=0.4,
)

# save multiple sizes, preserving aspect ratio
sizes = [None, 3.16, 4.21]
base_w, base_h = canvas.get_size_inches()
ratio = base_h / base_w
for w in sizes:
    if w is not None:
        canvas.set_size_inches(w, w * ratio)
        canvas.legends[0].set_bbox_to_anchor(compute_span_bbox(ax_grid))

    canvas.savefig(
        f"mvr_figure_{w}.png" if w is not None else "mvr_figure.png",
        bbox_inches="tight",
    )

Why the legend ignores your carefully computed bbox

Two interacting behaviors cause the drift. First, when using constrained layout, Matplotlib adjusts axes positions during savefig so the available space on the figure is used more effectively. The axes positions you observed when computing the bbox are not the final positions used at render time. Second, saving with bbox_inches="tight" changes the figure size and shape to tightly wrap its artists. A legend anchored in figure coordinates will then be positioned relative to this resized figure. If you remove layout="constrained" and stop using bbox_inches="tight", the legend lands where the bbox says—but ends up partially outside the figure, which is exactly why those layout features are commonly used.

The robust fix: give the legend its own axes

Instead of fighting the layout system, make it work for you. Add a dedicated axes solely for the legend. Constrained layout will then place all three axes—the legend container and the two plotting axes—consistently. The legend fills its own axes, spans exactly the plots’ width, and stays centered across size changes.

import matplotlib.pyplot as plt

board, panel_map = plt.subplot_mosaic(
    "LL;AB", figsize=(7, 4), layout="constrained", height_ratios=[1, 10]
)

# sample data
xs = [1, 2, 3, 4, 5]
ys_a = [1, 2, 3, 4, 5]
ys_b = [5, 4, 3, 2, 1]
panel_map["A"].plot(xs, ys_a, label="Line 1")
panel_map["B"].plot(xs, ys_a, label="Line 1")
panel_map["B"].plot(xs, ys_b, label="Line 2")

# gather unique handles/labels from A and B for legend in L
legend_handles, legend_labels = [], []
for key in "AB":
    hs, lbs = panel_map[key].get_legend_handles_labels()
    for h, lb in zip(hs, lbs):
        if lb not in legend_labels:
            legend_handles.append(h)
            legend_labels.append(lb)

# place the legend inside the legend axes and let it expand
panel_map["L"].legend(
    legend_handles,
    legend_labels,
    ncol=2,
    loc="center",
    mode="expand",
    borderaxespad=0,
    handletextpad=0.4,
)

# hide the legend axes frame
panel_map["L"].axis("off")

# save multiple sizes, preserving aspect ratio
sizes = [None, 3.16, 4.21]
base_w, base_h = board.get_size_inches()
ratio = base_h / base_w
for w in sizes:
    if w is not None:
        board.set_size_inches(w, w * ratio)
    board.savefig(
        f"mvr_figure_{w}.png" if w is not None else "mvr_figure.png",
    )

When exporting very small figures, constrained layout can warn that axes sizes collapse. That’s expected for extreme downsizing:

UserWarning: constrained_layout not applied because axes sizes collapsed to zero. Try making figure larger or Axes decorations smaller.

The other sizes render as intended.

Why it’s worth understanding this

Legend placement interacts with layout and export in subtle ways. Constrained layout adjusts axes during savefig, and bbox_inches="tight" resizes the canvas to fit artists. Any artist positioned in figure coordinates, including legends anchored with a bbox, can shift in the final output. A legend-only axes sidesteps this by letting the layout engine manage all positions coherently.

Takeaways

If you need a shared legend that aligns precisely with your subplot grid, treat the legend as a first-class layout participant. Put it in its own axes, let constrained layout handle spacing, and avoid relying on a figure-relative anchor that can become stale during save. If you must debug anchors, test without constrained layout and without tight bounding to verify the anchor itself, then switch to the legend-axes approach for production figures.

The article is based on a question from StackOverflow by BernhardWebstudio and an answer by RuthC.