1Subplots and Figure Layout
▶
One of the most powerful features in Matplotlib is the ability to arrange multiple plots within a single figure, creating what are called subplots or multi-panel figures. Rather than producing a series of isolated charts, subplots let you place related visualizations side by side or in a grid, making it far easier for readers to compare trends, distributions, or relationships across different datasets or variables. This is especially important in scientific publishing, data analysis reports, and dashboards, where a cohesive, well-organized figure communicates far more than a collection of separate images.
Understanding subplots requires grasping two core Matplotlib objects: the Figure (fig) and the Axes (ax or axs). The Figure is the entire canvas — the outermost container that holds everything. Each Axes is an individual plotting panel within that canvas. When you work with subplots, you are managing one Figure that contains multiple Axes objects, each of which behaves like its own independent plot.
Creating Subplots with plt.subplots()
The most common and convenient way to create a multi-panel figure is with plt.subplots(). You call it with the number of rows and columns you want in your grid, and it returns two objects: the Figure and an array of Axes.
import matplotlib.pyplot as plt
import numpy as np
fig, axs = plt.subplots(2, 3, figsize=(12, 6))
In this example, fig is the Figure object representing the entire canvas, and axs is a NumPy array of shape (2, 3) containing six individual Axes objects arranged in two rows and three columns. You access each panel using standard array indexing: axs[0, 0] is the top-left panel, axs[0, 2] is the top-right, axs[1, 1] is the center of the bottom row, and so on.
If you request only a single row or a single column — for example plt.subplots(1, 3) — the returned axs is a one-dimensional array, so you index it with a single integer: axs[0], axs[1], axs[2]. If you request a single subplot with plt.subplots(1, 1), axs is just a single Axes object, not an array at all.
The figsize=(width, height) keyword argument controls the overall size of the figure in inches. Matplotlib uses a default screen resolution of 100 dots per inch (dpi), so figsize=(12, 6) produces a figure 1200 by 600 pixels at that resolution. Choosing an appropriate figure size is important: too small and labels overlap or become unreadable; too large and the figure becomes unwieldy. A good rule of thumb is to allocate roughly 4 inches of width per column and 3 to 4 inches of height per row, then adjust based on content.
The sharex=True and sharey=True arguments are exceptionally useful when your subplots cover the same range of data. When sharex=True is set, all panels in the same column share the same x-axis limits and tick marks. When sharey=True is set, all panels in the same row share y-axis limits. This makes visual comparison immediate and eliminates the need for readers to mentally re-calibrate their expectations when scanning between panels.
fig, axs = plt.subplots(1, 2, figsize=(10, 4), sharex=True, sharey=True)
x = np.linspace(0, 10, 200)
axs[0].plot(x, np.sin(x), color='steelblue')
axs[1].plot(x, np.cos(x), color='tomato')
plt.tight_layout()
plt.show()
In this code, both panels share the same x range (0 to 10) and the same y range, so it is immediately clear that sine and cosine oscillate at the same frequency and amplitude. Without sharey=True, Matplotlib might auto-scale each panel independently, potentially creating misleading visual differences.
Plotting on Individual Axes
Once you have your array of Axes objects, you plot on each one using methods that belong directly to the Axes object rather than to pyplot. The Axes API mirrors the pyplot API almost exactly, but the key conceptual difference is that you are calling methods on a specific panel rather than on whatever the current active plot happens to be.
ax.plot(x, y)draws line plots, accepting the same keyword arguments asplt.plot():color,linewidth,linestyle,label, and so on.ax.scatter(x, y)draws scatter plots, with options for marker size (s), color (c), and transparency (alpha).ax.bar(categories, values)draws vertical bar charts.ax.barh()draws horizontal bar charts.ax.hist(data, bins=20)draws histograms, identical in behavior toplt.hist().
Here is a complete example that places four different plot types in a two-by-two grid:
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(42)
x = np.linspace(0, 2 * np.pi, 300)
data = rng.normal(loc=0, scale=1, size=500)
categories = ['A', 'B', 'C', 'D']
values = [23, 45, 12, 67]
fig, axs = plt.subplots(2, 2, figsize=(10, 8))
# Top-left: line plot
axs[0, 0].plot(x, np.sin(x), color='steelblue', linewidth=2, label='sin(x)')
axs[0, 0].set_title('Line Plot')
axs[0, 0].set_xlabel('x')
axs[0, 0].set_ylabel('sin(x)')
axs[0, 0].legend()
# Top-right: scatter plot
axs[0, 1].scatter(rng.uniform(0, 10, 100), rng.uniform(0, 10, 100),
alpha=0.6, color='tomato', s=40, label='Random Points')
axs[0, 1].set_title('Scatter Plot')
axs[0, 1].set_xlabel('x')
axs[0, 1].set_ylabel('y')
axs[0, 1].legend()
# Bottom-left: bar chart
axs[1, 0].bar(categories, values, color='mediumseagreen', edgecolor='black')
axs[1, 0].set_title('Bar Chart')
axs[1, 0].set_xlabel('Category')
axs[1, 0].set_ylabel('Value')
# Bottom-right: histogram
axs[1, 1].hist(data, bins=25, color='mediumpurple', edgecolor='white', alpha=0.8)
axs[1, 1].set_title('Histogram')
axs[1, 1].set_xlabel('Value')
axs[1, 1].set_ylabel('Frequency')
plt.tight_layout()
plt.show()
Notice that each panel is addressed explicitly by its row-column index. The label and title methods — ax.set_title(), ax.set_xlabel(), and ax.set_ylabel() — are called on each specific Axes, so they apply only to that panel. This is in contrast to using plt.title(), which would only affect the currently active panel and could produce unexpected results in a multi-panel figure.
ax.legend() similarly scopes the legend to its specific panel. If you set label='...' on a line or scatter call inside axs[0, 0] and then call axs[0, 0].legend(), the legend appears only in that top-left panel. This is exactly what you want in a multi-panel figure where each panel has its own independent content and annotation.
Adjusting Layout and Spacing
A common frustration when creating subplot grids is that titles, axis labels, and tick marks from adjacent panels collide with each other, producing an ugly and unreadable figure. Matplotlib provides several tools for dealing with this.
plt.tight_layout() is the simplest and most commonly used solution. When called after all plotting commands, it automatically analyzes the sizes of all labels, ticks, and titles and adjusts the spacing between subplots to prevent overlap. In the vast majority of cases, adding plt.tight_layout() just before plt.show() is sufficient to produce a clean figure. It accepts an optional pad argument that controls how much extra padding to add around the figure edges, and a rect argument that restricts the layout to a specific region of the figure (useful when you have a fig.suptitle() that would otherwise be cut off).
plt.tight_layout(pad=1.5)
When you need more precise control, plt.subplots_adjust() lets you set the spacing manually. The key parameters are:
hspace: the height of the vertical gap between rows, as a fraction of the average Axes height. A value of0.4gives a moderate gap;0.0eliminates it entirely.wspace: the width of the horizontal gap between columns, as a fraction of the average Axes width.left,right,top,bottom: the positions of the outer edges of the subplot area, as fractions of the figure width and height. For example,top=0.9reserves the top 10% of the figure for a super-title.
plt.subplots_adjust(hspace=0.4, wspace=0.3, top=0.88)
tight_layout() and subplots_adjust() can conflict with each other if called together, because tight_layout() will overwrite any manual adjustments. Use one or the other: prefer tight_layout() for convenience and fall back to subplots_adjust() when you need fine-grained control.
fig.suptitle() adds a single centered title that spans the entire figure, sitting above all subplot panels. This is useful for giving the overall figure a descriptive heading while individual panels retain their own per-panel titles.
fig, axs = plt.subplots(1, 2, figsize=(10, 4))
axs[0].plot(x, np.sin(x))
axs[0].set_title('Sine Wave')
axs[1].plot(x, np.cos(x))
axs[1].set_title('Cosine Wave')
fig.suptitle('Trigonometric Functions', fontsize=16, fontweight='bold')
plt.tight_layout(rect=[0, 0, 1, 0.95]) # leave space for suptitle
plt.show()
The rect=[0, 0, 1, 0.95] argument to tight_layout() tells it to confine the subplot area to the bottom 95% of the figure, leaving the top 5% clear for the super-title. Without this, the super-title often overlaps with the top row of subplot titles.
Using GridSpec for Complex Layouts
The uniform grid produced by plt.subplots() is excellent for symmetric layouts, but many real-world figures require panels of different sizes — for example, one wide panel spanning the full width at the top with two narrower panels below it, or a large central panel flanked by smaller annotation panels. For these cases, GridSpec provides complete control over the layout.
GridSpec is imported from matplotlib.gridspec and instantiated with a row count and a column count. You can think of it as defining an invisible grid of cells, where each cell is a potential home for a subplot. The power comes from the fact that a single subplot can occupy multiple cells by slicing the GridSpec object using standard Python slice notation.
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import numpy as np
fig = plt.figure(figsize=(12, 8))
gs = gridspec.GridSpec(2, 3) # 2 rows, 3 columns
With the GridSpec defined, you create individual Axes objects by passing slices of gs to fig.add_subplot().
gs[0, :]— row 0, all columns: a single panel that spans the full width of the top row.gs[1, 0]— row 1, column 0: a single cell in the bottom-left.gs[1, 1:3]— row 1, columns 1 and 2: a panel spanning the right two-thirds of the bottom row.
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import numpy as np
x = np.linspace(0, 10, 300)
rng = np.random.default_rng(0)
fig = plt.figure(figsize=(12, 8))
gs = gridspec.GridSpec(2, 3, figure=fig, hspace=0.4, wspace=0.35)
# Full-width top panel
ax_top = fig.add_subplot(gs[0, :])
ax_top.plot(x, np.sin(x) * np.exp(-0.1 * x), color='steelblue', linewidth=2)
ax_top.set_title('Damped Sine Wave (Full Width)')
ax_top.set_xlabel('Time')
ax_top.set_ylabel('Amplitude')
# Bottom-left panel (single cell)
ax_bl = fig.add_subplot(gs[1, 0])
ax_bl.hist(rng.normal(0, 1, 500), bins=20, color='tomato', edgecolor='white')
ax_bl.set_title('Distribution')
ax_bl.set_xlabel('Value')
ax_bl.set_ylabel('Count')
# Bottom-center panel (single cell)
ax_bc = fig.add_subplot(gs[1, 1])
ax_bc.scatter(rng.uniform(0, 5, 80), rng.uniform(0, 5, 80),
color='mediumseagreen', alpha=0.7)
ax_bc.set_title('Scatter')
ax_bc.set_xlabel('x')
ax_bc.set_ylabel('y')
# Bottom-right panel (single cell)
ax_br = fig.add_subplot(gs[1, 2])
ax_br.bar(['X', 'Y', 'Z'], [34, 67, 45], color='mediumpurple', edgecolor='black')
ax_br.set_title('Bar Chart')
ax_br.set_xlabel('Group')
ax_br.set_ylabel('Value')
fig.suptitle('Complex GridSpec Layout', fontsize=16, fontweight='bold', y=1.01)
plt.show()
This produces a figure where the top half is occupied entirely by one wide plot, and the bottom half is split into three narrower panels. The key is the slicing syntax: gs[0, :] uses Python's slice notation to say "row 0, all columns," exactly as you would slice a NumPy array. gs[1, 1:3] would span columns 1 and 2 of row 1, creating a panel twice the width of the single-cell panel beside it.
GridSpec also accepts hspace and wspace directly in its constructor, which is often cleaner than calling plt.subplots_adjust() separately. You can also specify width_ratios and height_ratios to make certain rows or columns proportionally larger than others:
gs = gridspec.GridSpec(2, 2,
width_ratios=[2, 1],
height_ratios=[1, 2],
hspace=0.3,
wspace=0.3)
This creates a grid where the left column is twice as wide as the right column, and the bottom row is twice as tall as the top row. Such layouts are invaluable when you want a large primary plot accompanied by smaller detail or annotation panels.
It is worth noting that plt.subplots() itself uses GridSpec internally, and for simple uniform grids it is far less verbose. Reserve GridSpec for situations where you genuinely need panels of different sizes or irregular arrangements. When your layout is uniform, plt.subplots(nrows, ncols) with tight_layout() will serve you well in almost every case.
Together, these tools — plt.subplots() for uniform grids, the Axes API for per-panel plotting and labeling, tight_layout() and subplots_adjust() for spacing, fig.suptitle() for figure-level annotation, and GridSpec for complex custom layouts — give you complete control over multi-panel figures in Matplotlib, enabling you to produce professional, publication-ready visualizations that communicate complex data clearly and efficiently.