I keep seeing the same problem in code reviews and notebooks: someone has a great dataset, but their plots are scattered across separate figures or stacked in a way that hides the relationship between them. When you need to compare trends, diagnose model drift, or present multiple views of the same signal, a single well-structured figure beats five separate windows every time. That’s where matplotlib.pyplot.subplots() earns its keep. It gives you a clean, predictable way to place multiple axes inside one figure, which is the difference between a plot that merely exists and a plot that communicates.
In this post I’ll show you how I use subplots in real projects: side-by-side comparisons, stacked trends, and grids that mix plot types. You’ll get complete, runnable examples, the mental model that keeps the API consistent, and the pitfalls I see most often when teams move from quick scripts to production-quality notebooks. I’ll also include a few modern patterns I rely on in 2026, including layout management and AI-assisted plotting checks that keep visual regressions out of reports.
The mental model: figure, axes, and why subplots feels predictable
If you only remember one concept, make it this: a Matplotlib figure is a container, and each plot lives on an axes object. The subplots() helper creates a figure and a grid of axes in one call. That’s it. Once you see axes as the primary unit, plotting becomes consistent whether you’re making one chart or twelve.
I think of plt.subplots() as “give me a figure and a matrix of axes.” If you ask for one row and one column, it returns a single axes. If you ask for multiple rows or columns, you get an array of axes. Everything you do afterward is just method calls on those axes: plot, scatter, settitle, setxlabel, and so on.
Here’s a minimal plot that uses the stateful interface (via plt) just to set the stage:
import matplotlib.pyplot as plt
plt.plot([1, 2, 3, 4], [16, 4, 1, 8])
plt.title("Simple line")
plt.xlabel("Step")
plt.ylabel("Score")
plt.show()
Now let’s evolve that into subplots. The mental model doesn’t change; you just target specific axes.
A quick, practical refresher on subplots()
The signature I use most often is:
fig, ax = plt.subplots(nrows=1, ncols=1)
The function returns a Figure and an Axes (or array of axes). You can think of fig as a canvas and ax as the drawing region. If you request multiple axes, ax becomes an array that you index by position.
A key tip: if you’re unsure whether you’ll get a single axes or an array, you can normalize with np.atleast_1d or np.ravel. But I usually handle it with a simple pattern: in 1×1 cases I still call the axes ax, and in multi-axes cases I call it axs to remind myself it’s a collection.
Here’s a basic two-panel layout using a single row and two columns:
import matplotlib.pyplot as plt
import numpy as np
# Data
x = np.array([1, 2, 3, 4])
y_left = np.array([10, 20, 25, 30])
y_right = np.array([30, 25, 20, 10])
fig, axs = plt.subplots(nrows=1, ncols=2, figsize=(8, 3))
axs[0].plot(x, y_left, color="tab:blue")
axs[0].set_title("Rising")
axs[0].set_xlabel("Step")
axs[0].set_ylabel("Value")
axs[1].plot(x, y_right, color="tab:orange")
axs[1].set_title("Falling")
axs[1].set_xlabel("Step")
fig.suptitle("Side-by-side comparison")
fig.tight_layout()
plt.show()
If you’ve used plt.subplot() before, the difference is mostly ergonomics. subplots() returns axes objects directly, which is safer and clearer when figures become complex.
Stateful vs object-oriented usage (and what I recommend in 2026)
Matplotlib supports a stateful API (plt.plot(...)) and an object-oriented API (ax.plot(...)). Both work, but I strongly prefer the object-oriented style for multi-panel figures. It reduces surprises when you have multiple axes active and makes it obvious which panel you’re modifying.
Here’s the contrast in a quick table I use when teaching teams how to standardize plots:
Typical use
What I recommend
—
—
plt.) Quick, single-plot scripts
Use only for quick checks
ax.) Multi-panel, reusable figures
Default for all serious plotsI’m not dogmatic about it, but once you start working with more than one axes, the object-oriented style saves time and reduces bugs. It also plays nicely with subplots() because you already have the axes objects in hand.
Building grids you can reason about
Multi-panel figures usually fall into one of three patterns: a row, a column, or a grid. I rely on subplots() for all three.
Single column (stacked plots)
Stacked plots are great for time-series comparisons. I often share the x-axis so panning and labels stay clean.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 2 * np.pi, 400)
y1 = np.sin(x)
y2 = np.sin(x 2)
fig, (ax1, ax2) = plt.subplots(nrows=2, ncols=1, sharex=True, figsize=(8, 4))
ax1.plot(x, y1, color="tab:red")
ax1.set_ylabel("sin(x)")
ax2.plot(x, y2, color="tab:purple")
ax2.set_ylabel("sin(x^2)")
ax2.set_xlabel("radians")
fig.suptitle("Stacked with shared x")
fig.tight_layout()
plt.show()
Grid (2×2 and beyond)
When you move to grids, axes are indexed like a matrix: axs[row, col]. That indexing is a big advantage over plt.subplot() because you can pass axes around like regular variables.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 2 * np.pi, 400)
y1 = np.sin(x)
y2 = np.sin(x 2)
y3 = y1 2
y4 = y2 2
fig, axs = plt.subplots(nrows=2, ncols=2, figsize=(9, 6))
axs[0, 0].plot(x, y1, color="tab:red")
axs[0, 0].set_title("sin(x)")
axs[0, 1].plot(x, y2, color="tab:orange")
axs[0, 1].set_title("sin(x^2)")
axs[1, 0].plot(x, y3, color="tab:blue")
axs[1, 0].set_title("sin(x)^2")
axs[1, 1].plot(x, y4, color="tab:green")
axs[1, 1].set_title("sin(x^2)^2")
fig.suptitle("2×2 grid")
fig.tight_layout()
plt.show()
If you need to iterate over all axes, you can flatten the array:
for ax in axs.ravel():
ax.grid(True, alpha=0.2)
When you should use plt.subplot() instead
plt.subplot() is still fine for quick sketches, especially when you want to add subplots one by one in a stateful flow. But once you need shared axes, annotations across multiple panels, or anything more than a 1×2 grid, I recommend subplots().
Sharing axes and tightening labels
Shared axes aren’t just a convenience; they’re a correctness feature. When you compare two trends, shared scale keeps your eyes honest. I use sharex=True or sharey=True whenever the ranges are meaningfully comparable.
Here’s a layout where the y-axis is shared so you can compare two runs without bias:
import matplotlib.pyplot as plt
import numpy as np
x = np.arange(10)
run_a = np.array([3, 4, 5, 7, 8, 9, 8, 7, 6, 5])
run_b = np.array([2, 3, 4, 6, 7, 7, 6, 6, 5, 4])
fig, axs = plt.subplots(nrows=1, ncols=2, sharey=True, figsize=(8, 3))
axs[0].plot(x, run_a, color="tab:blue")
axs[0].set_title("Run A")
axs[0].set_xlabel("Epoch")
axs[0].set_ylabel("Accuracy")
axs[1].plot(x, run_b, color="tab:orange")
axs[1].set_title("Run B")
axs[1].set_xlabel("Epoch")
fig.suptitle("Shared y-axis for fair comparison")
fig.tight_layout()
plt.show()
For label management, I rely on fig.tightlayout() as a baseline. In 2026, I also reach for constrainedlayout=True on the figure in notebooks, especially when labels are long. It saves you from manual tweaks, though it can be slower on very large grids.
fig, axs = plt.subplots(2, 2, constrained_layout=True, figsize=(9, 6))
Mixing projections and plot types
You’re not limited to Cartesian axes. You can pass axis-specific configuration using subplot_kw. A common example is polar plots. Here’s a grid that mixes line and scatter on polar axes:
import matplotlib.pyplot as plt
import numpy as np
theta = np.linspace(0, 1.5 * np.pi, 100)
r = np.sin(theta 2) + np.cos(theta 2)
fig, axs = plt.subplots(nrows=2, ncols=2, subplot_kw={"projection": "polar"}, figsize=(7, 6))
axs[0, 0].plot(theta, r, color="tab:blue")
axs[1, 1].scatter(theta, r, color="tab:red", s=10)
# Leave two panels empty on purpose for contrast
axs[0, 1].set_title("Empty panel")
axs[1, 0].set_title("Empty panel")
fig.suptitle("Polar subplots")
fig.tight_layout()
plt.show()
If you need different projections in the same figure, I typically combine subplots() with add_subplot() for the special-case axes. It’s a clean way to keep most panels consistent while customizing a few.
Layout control beyond the basics
subplots() solves 80% of layout needs, but I keep a few advanced tools in reach:
gridspec: useful when you need panels of different sizes or a column dedicated to a legend.subplot_mosaic: great when the layout is best described by a labeled grid.constrained_layout: auto-adjusts spacing for complex labels.
Here’s a subplot_mosaic example that labels axes by name, which makes larger layouts much easier to read and maintain:
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 1, 100)
mosaic = [
["left", "right"], ["bottom", "bottom"],]
fig, axs = plt.subplot_mosaic(mosaic, figsize=(8, 5))
axs["left"].plot(x, x, color="tab:blue")
axs["left"].set_title("Left panel")
axs["right"].plot(x, x 2, color="tab:orange")
axs["right"].set_title("Right panel")
axs["bottom"].plot(x, np.sqrt(x), color="tab:green")
axs["bottom"].set_title("Bottom panel")
fig.tight_layout()
plt.show()
When I collaborate with teams, I prefer this style for dashboards and report figures because it communicates intent and keeps indexing mistakes low.
Common mistakes I see (and how to avoid them)
I’ll keep this short and practical. These are the mistakes I see in reviews, and the fix I suggest every time.
1) Forgetting that subplots() returns an array for multiple axes
- Symptom:
ax.plot(...)throws an error becauseaxis actually an array. - Fix: name it
axsand index it, or normalize withaxs = np.atleast_1d(axs).
2) Calling plt.plot() after creating subplots
- Symptom: the line appears on the last active axes, not the one you intended.
- Fix: call
axs[i].plot()so you always target the correct panel.
3) Misunderstanding sharex / sharey
- Symptom: you expect linked axes but scales still differ.
- Fix: use
sharex=Trueorsharey=Trueinsubplots()and avoid manually setting limits per axes.
4) Overlapping titles and labels
- Symptom: suptitle overlaps with top row labels.
- Fix: call
fig.tightlayout()or useconstrainedlayout=True.
5) Mixing indexing styles
- Symptom:
axs[0]works for a 1×2 layout but fails for 2×2. - Fix: treat
axsas a 2D array for grids; useaxs.ravel()for loops.
6) Forgetting figsize
- Symptom: labels overlap or are unreadable, especially in notebooks.
- Fix: set
figsizeto match the space you actually have.
When I do not use subplots
Subplots are powerful, but not always the best choice. I skip them in a few situations:
- If I’m writing a one-off quick check and the comparison doesn’t matter, a single plot is faster.
- If the figure is going into a report with strict layout requirements, I might build a custom layout with
GridSpecfor precise control. - If the plots are unrelated, I keep them separate. Cramming unrelated plots together can confuse rather than clarify.
A simple way to decide: if you want a reader to compare panels, use subplots. If you want them to read panels independently, separate figures are usually clearer.
Performance and real-world scale
Matplotlib can handle large grids, but it’s not free. Here’s what I watch in production notebooks and report pipelines:
- Rendering time scales with the number of artists. A 6×6 grid with multiple lines per axes can take noticeably longer to draw.
- Complex layouts with
constrainedlayoutcan add overhead. If I’m generating many figures in batch, I often stick totightlayout()and manual padding. - For large dashboards, I precompute data and keep plotting logic lightweight. Plotting usually takes longer than the math, especially when labels are heavy.
If you’re automating reports, I recommend caching data and saving figures to file rather than relying on interactive redraws. Typical rendering time for a moderately complex 2×2 figure is in the 10–30 ms range on a modern laptop, but it can climb to 100–200 ms when you have large scatter plots or dense time series. For a batch job with dozens of figures, those numbers add up.
A practical pattern I use in 2026 notebooks
In 2026 I often pair Matplotlib with AI-assisted checks. The idea is simple: generate the plot, then run a lightweight vision check to confirm labels and layout. This catches issues like missing axes labels or overlapping titles without human eyeballing every figure.
Here’s the plotting side I standardize in templates:
import matplotlib.pyplot as plt
import numpy as np
def plottrainingvs_validation(steps, train, val):
fig, axs = plt.subplots(nrows=1, ncols=2, sharey=True, figsize=(8, 3))
axs[0].plot(steps, train, color="tab:blue")
axs[0].set_title("Training")
axs[0].set_xlabel("Step")
axs[0].set_ylabel("Loss")
axs[1].plot(steps, val, color="tab:orange")
axs[1].set_title("Validation")
axs[1].set_xlabel("Step")
fig.suptitle("Loss curves")
fig.tight_layout()
return fig
That pattern keeps plots consistent across projects and makes layout changes predictable. It also makes it easy to embed the figure in reports or dashboards.
A short checklist you can reuse
I keep this checklist in mind before I ship a figure with subplots:
- Are axes scales shared where comparison matters?
- Are titles and labels readable at the final figure size?
- Did I use
tightlayout()orconstrainedlayoutto avoid overlap? - Are colors consistent across panels when data is related?
- Did I use object-oriented calls on each axes instead of stateful calls?
Following this checklist usually prevents the “why does this plot look off?” review comment.
Key takeaways and what I’d do next
I’ve found that subplots() is less about convenience and more about clarity. It gives you a reliable structure for multi-panel figures, and once you adopt the axes-first mental model, every other Matplotlib feature becomes easier to understand. If you take one action after reading this, make it a small refactor: pick one plot you currently make in separate figures and recreate it as a two-panel or stacked layout using subplots(). You’ll see the comparison immediately, and your readers will too.
If you’re moving from quick scripts to shared notebooks, standardize on the object-oriented style and pass axes around like normal variables. It removes ambiguity and makes your plotting code easier to maintain. I also recommend adding a layout check step for reports—whether that’s a quick visual inspection or an automated snapshot test—because layout bugs are the most common visual regressions I see.
From there, experiment with shared axes, subplot_mosaic, and a small palette that keeps related panels visually consistent. That combination gives you a professional, readable figure without the complexity of a full dashboarding framework. Once you’re comfortable, you can mix projections or integrate GridSpec for custom layouts, but you’ll be surprised how far subplots() can take you on its own.


