When I am exploring data, the moment I trust a single plot is the moment I miss a pattern. Most real projects need comparisons: revenue vs. churn, latency vs. error rate, week-over-week vs. month-over-month. I reach for subplots() because it turns a figure into a small dashboard you can reason about. Think of a figure as a bulletin board and each subplot as a pinned card. You are not just drawing multiple graphs; you are arranging a narrative so your eyes can move across it without mental gymnastics. In this post I focus on the matplotlib.pyplot.subplots() workflow, the object model behind it, and the layout patterns that stay readable when a notebook turns into a report. I also call out the mistakes I see most often, when you should skip subplots entirely, and what performance feels like in 2026 on typical laptops. If you have used plt.plot() before, this is a natural next step that makes multi-plot work feel disciplined rather than accidental.
Why subplots() Is My Default for Multi-Plot Work
I treat subplots() as the layout engine of Matplotlib. It gives you a predictable grid, a figure object to manage global settings, and a clean handle to each axes. The alternative is a tangle of stateful calls that rely on global state and hidden order. That can be fine for quick sketches, but it breaks down once you add titles, labels, and multiple datasets.
Here is the mental model I use: the figure is the page, and each axes is a drawing area on that page. When you call subplots(), you are choosing where those drawing areas live. This model matters because layout issues are not bugs in your data. They are consequences of how you placed axes in the figure. If you accept that, fixing layout becomes mechanical rather than frustrating.
I also like subplots() because it scales with the way teams work in 2026. Most teams I work with ship notebooks into reports, dashboards, or internal docs. With subplots(), you can keep the plotting logic modular and attach titles, legends, and annotations in a predictable way. It pairs well with AI-assisted code completion too, because the axes variable names are explicit and stable. When a code assistant suggests ax[1, 0].set_xlabel(‘Time (s)‘), you can tell immediately whether it is correct.
As a quick baseline, here is a single plot you can build on later. I include it because many mistakes in multi-plot layouts start with skipping the basic pattern of creating a figure and axes intentionally.
import matplotlib.pyplot as plt
x = [1, 2, 3, 4]
y = [16, 4, 1, 8]
plt.plot(x, y)
plt.title(‘Simple signal‘)
plt.xlabel(‘Sample‘)
plt.ylabel(‘Value‘)
plt.show()
From Single Plot to First Grid: subplots() Basics
The simplest use of subplots() is a single row or column. You provide nrows and ncols, then plot on the axes you get back. I like to start with a side-by-side pair because it forces you to think about comparability: are the scales aligned, are the labels clear, and is the story readable left to right?
import matplotlib.pyplot as plt
import numpy as np
Two related series that invite comparison
weeks = np.array([1, 2, 3, 4])
new_signups = np.array([120, 180, 160, 210])
active_users = np.array([980, 1020, 1015, 1080])
fig, ax = plt.subplots(1, 2, figsize=(10, 4))
ax[0].plot(weeks, new_signups, color=‘tab:blue‘)
ax[0].set_title(‘New signups‘)
ax[0].set_xlabel(‘Week‘)
ax[0].set_ylabel(‘Users‘)
ax[1].plot(weeks, active_users, color=‘tab:green‘)
ax[1].set_title(‘Active users‘)
ax[1].set_xlabel(‘Week‘)
ax[1].set_ylabel(‘Users‘)
fig.suptitle(‘Weekly growth snapshot‘)
plt.show()
Notice that the axes are stored in a NumPy-like array. When you set nrows=1, ncols=2, you get a one-dimensional array. When you set a larger grid, you get a two-dimensional array. This shape detail is a source of real bugs, so I treat it as part of the API contract rather than a nuisance.
I also want to distinguish plt.subplot() from plt.subplots() because the names are easy to mix up and the workflows are different. plt.subplot() is the older stateful approach that creates a single axes in a grid slot and switches the current axes implicitly. plt.subplots() creates the full grid at once and gives you explicit handles. If you are building anything more than a quick sketch, I recommend the second approach.
Here is a short comparison that I use when mentoring new engineers.
Modern object-oriented pattern
—
plt.subplot(2, 2, 1) fig, ax = plt.subplots(2, 2)
Explicit axes references
Easy to rearrange and test
Makes plotting functions reusable## fig and ax: The Object-Oriented Model That Saves You Later
Once you embrace fig and ax, your plots stop feeling fragile. I like to think of fig as the owner of global settings and ax as the owner of local drawing behavior. Titles, legends, and labels belong to axes. Figure-level labels, overall title, and layout are managed at the figure level. That separation is worth keeping, because it makes later changes local and safe.
A common pattern I use is to wrap plotting into functions that take ax as a parameter. This makes your code testable and helps you reuse plotting logic across notebooks and scripts.
import matplotlib.pyplot as plt
import numpy as np
def plotlatency(ax, timestamps, latencyms, label):
ax.plot(timestamps, latency_ms, label=label, color=‘tab:purple‘)
ax.set_xlabel(‘Time (s)‘)
ax.set_ylabel(‘Latency (ms)‘)
ax.grid(True, alpha=0.3)
ax.legend(loc=‘upper right‘)
timestamps = np.arange(0, 10)
api_latency = np.array([120, 110, 150, 130, 140, 160, 155, 145, 150, 135])
worker_latency = np.array([90, 95, 92, 98, 100, 105, 110, 108, 102, 96])
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
plotlatency(ax[0], timestamps, apilatency, label=‘API‘)
plotlatency(ax[1], timestamps, workerlatency, label=‘Worker‘)
fig.suptitle(‘Service latency comparison‘)
plt.show()
This pattern is not just about style. It is about unit testing and code review. When plotting logic is a function that takes an axes, your tests can call it with a test axes and confirm the labels and line counts without rewriting logic. When someone else reviews your code, they can read the plotting function in isolation.
I also recommend being intentional about the figsize early. I pick a size that matches the intended display. For notebooks on a standard laptop, I often start with (10, 4) for side-by-side and (8, 8) for a 2×2 grid. For reports or slides, I adjust to match the target aspect ratio. This avoids the common trap of squished plots where labels fight for space.
Working with Grids: Shapes, Indexing, and Layout Control
Grids are where subplots() shines and where most bugs appear. The first trick is to always know the shape of ax. If you are not sure, print ax.shape or use np.atleast2d(ax) to normalize. I often wrap ax with np.atleast2d so I can always index with [row, col] even for a 1xN layout.
Here is a 2×2 example with clear indexing and titles. It is a pattern you can reuse for comparison dashboards.
import numpy as np
import matplotlib.pyplot as plt
Toy data for four related signals
x = np.linspace(0, 2 * np.pi, 400)
primary = np.sin(x)
secondary = np.sin(x 2)
primary_sq = primary 2
secondary_sq = secondary 2
fig, ax = plt.subplots(2, 2, figsize=(10, 8))
ax[0, 0].plot(x, primary, color=‘tab:red‘)
ax[0, 0].set_title(‘sin(x)‘)
ax[0, 1].plot(x, secondary, color=‘tab:red‘)
ax[0, 1].set_title(‘sin(x^2)‘)
ax[1, 0].plot(x, primary_sq, color=‘tab:blue‘)
ax[1, 0].set_title(‘sin(x)^2‘)
ax[1, 1].plot(x, secondary_sq, color=‘tab:blue‘)
ax[1, 1].set_title(‘sin(x^2)^2‘)
fig.suptitle(‘2×2 comparison grid‘)
plt.show()
When grids get dense, layout tools matter more than colors or labels. I typically set constrained_layout=True on the figure when I know I will add titles and axis labels. It reduces overlap without me tuning spacing by hand.
fig, ax = plt.subplots(2, 2, figsize=(10, 8), constrained_layout=True)
You can also adjust spacing manually with fig.subplotsadjust(wspace=0.3, hspace=0.4). I use that when a layout needs artistic control, like a report cover page, but I prefer constrainedlayout for day-to-day work because it is consistent and predictable.
If you need a non-rectangular layout, GridSpec is your friend. I do not show it here because the focus is subplots(), but I will say that subplots() and GridSpec can work together. You can create a GridSpec and then call fig.add_subplot(gs[0, :]) for a wide plot on top and two smaller plots beneath. That is the pattern I use when I want a high-level trend with two supporting panels.
Shared Axes, Scales, and Layout Management
Comparisons only work if the eye has a shared reference. That is why sharex and sharey are so useful. When I stack plots vertically, I almost always set sharex=True so the x-axis is aligned. The same is true for side-by-side plots with a shared y-axis.
import numpy as np
import matplotlib.pyplot as plt
x = np.linspace(0, 2 * np.pi, 400)
series_a = np.sin(x)
series_b = np.sin(x 2)
fig, (ax1, ax2) = plt.subplots(2, 1, sharex=True, figsize=(8, 6))
ax1.plot(x, series_a, color=‘tab:orange‘)
ax1.set_ylabel(‘sin(x)‘)
ax2.plot(x, series_b, color=‘tab:orange‘)
ax2.set_ylabel(‘sin(x^2)‘)
ax2.set_xlabel(‘Angle (radians)‘)
fig.suptitle(‘Shared x-axis for better comparison‘)
plt.show()
A subtle but important point: shared axes also share tick locators and limits. That means if one subplot needs a different scale, you should not share that axis. I have seen charts where a single outlier forced both plots to expand and made the comparison useless. If you expect very different ranges, keep axes independent and use consistent labeling instead.
For layout, I mostly choose between constrainedlayout and tightlayout. The first works best with complex figures, while tightlayout is a fast fix after the fact. I use tightlayout when I do not want to modify my subplots() call. Example:
fig, ax = plt.subplots(1, 2, figsize=(10, 4))
… plot calls …
fig.tight_layout()
plt.show()
For fine control, fig.subplots_adjust() gives direct spacing values in figure fractions. I only use it when I am matching a report template or a slide deck with strict margins.
Beyond Cartesian: Projections, Polar, and Mixed Types
subplots() also supports specialized axes through subplot_kw. I use this when I need polar plots for phase or directional data. For example, wind direction or antenna gain patterns are often easier to read on polar axes.
import numpy as np
import matplotlib.pyplot as plt
angles = np.linspace(0, 2 * np.pi, 200)
response = np.abs(np.sin(2 * angles)) + 0.2
fig, ax = plt.subplots(1, 2, figsize=(10, 4), subplot_kw={‘projection‘: ‘polar‘})
ax[0].plot(angles, response, color=‘tab:cyan‘)
ax[0].set_title(‘Polar response A‘)
ax[1].scatter(angles, response, s=10, color=‘tab:cyan‘)
ax[1].set_title(‘Polar response B‘)
fig.suptitle(‘Polar subplots‘)
plt.show()
You can also mix projections in a single figure, but you need to create axes explicitly in that case. The trick is to call fig.addsubplot() for the special axes and keep the rest in a grid. If you find that too verbose, you can use subplotkw for the full grid and reserve a separate figure for the odd plot. I make that decision based on the story I want to tell: if the polar plot is part of the comparison, it lives in the same figure; if it is a supporting detail, it gets its own figure.
A Practical Workflow: Plotting Functions + Subplots Skeleton
When I want to ship a report-worthy figure, I treat subplots() like a skeleton and fill it with small plotting functions. This is the highest leverage pattern I know because it keeps plot details local, reduces duplication, and makes it easy to swap data sources. Here is a more complete example that mirrors what I do in a real analytics notebook: KPI trends, distribution, and cohort breakdowns on a single page.
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(4)
weeks = np.arange(1, 13)
revenue = np.array([92, 98, 105, 110, 108, 120, 128, 125, 132, 140, 138, 145])
churn = np.array([0.045, 0.05, 0.044, 0.048, 0.046, 0.043, 0.04, 0.042, 0.041, 0.038, 0.039, 0.036])
segments = [‘SMB‘, ‘Mid‘, ‘Enterprise‘]
segment_growth = np.array([0.12, 0.08, 0.04])
Synthetic distribution of order values
order_values = np.random.gamma(shape=2.0, scale=35.0, size=400)
def plot_revenue(ax):
ax.plot(weeks, revenue, marker=‘o‘, color=‘tab:blue‘)
ax.set_title(‘Weekly revenue‘)
ax.set_xlabel(‘Week‘)
ax.set_ylabel(‘USD (k)‘)
ax.grid(True, alpha=0.3)
def plot_churn(ax):
ax.plot(weeks, churn * 100, marker=‘o‘, color=‘tab:red‘)
ax.set_title(‘Weekly churn‘)
ax.set_xlabel(‘Week‘)
ax.set_ylabel(‘Churn (%)‘)
ax.grid(True, alpha=0.3)
def plot_distribution(ax):
ax.hist(order_values, bins=20, color=‘tab:green‘, alpha=0.8)
ax.set_title(‘Order value distribution‘)
ax.set_xlabel(‘Order value‘)
ax.set_ylabel(‘Count‘)
def plot_segments(ax):
ax.bar(segments, segment_growth * 100, color=‘tab:purple‘)
ax.set_title(‘Segment growth (QoQ)‘)
ax.set_ylabel(‘Growth (%)‘)
fig, ax = plt.subplots(2, 2, figsize=(12, 8), constrained_layout=True)
plot_revenue(ax[0, 0])
plot_churn(ax[0, 1])
plot_distribution(ax[1, 0])
plot_segments(ax[1, 1])
fig.suptitle(‘Business snapshot: revenue, churn, and segments‘)
plt.show()
What I like about this approach is that it keeps every subplot readable in isolation. If I later want to rearrange the grid to make the distribution larger, I can change the layout without rewriting the plotting logic.
Edge Cases That Bite: Axes Arrays, Single Axes, and Squeeze
Here is an edge case that always trips people: when nrows and ncols are both 1, subplots() returns a single Axes object, not an array. That means ax[0] fails because ax is not subscriptable. You can avoid that with squeeze=False, which forces the axes to be a 2D array even for 1×1.
import matplotlib.pyplot as plt
fig, ax = plt.subplots(1, 1, squeeze=False)
ax[0, 0].plot([0, 1, 2], [0, 1, 0])
ax[0, 0].set_title(‘Stable indexing with squeeze=False‘)
plt.show()
This is a small detail, but it matters when you are writing generic plotting utilities that might build 1×1, 1xN, or Nx1 grids based on parameters. I often default to squeeze=False when I know I will index by [row, col] no matter what.
Another edge case is when you use sharex or sharey and then try to hide tick labels. If you call ax.labelouter() on a grid, Matplotlib will hide inner tick labels for shared axes so the grid looks clean. This is great for dense layouts. But it is confusing when you are debugging because your labels seem to disappear. I recommend adding ax.labelouter() deliberately so you know what is happening, or avoid it until your grid is stable.
Layout Strategy: Choosing the Right Grid for the Story
People often choose grid shape based on what looks neat rather than what tells the story. I try to choose a grid based on the comparisons I want the reader to make.
- If I want temporal comparisons, I stack plots vertically with shared x-axis.
- If I want categorical comparisons, I put plots side-by-side and share y-axis.
- If I want a summary + details, I use a tall top row and two smaller plots below (GridSpec or
subplots()with a wide top panel). - If I want to compare distributions across segments, I prefer a 2×2 or 3×1 grid so each segment has its own panel.
Here is a conceptual example using a wide top panel for an overall metric and smaller panels for breakdowns. The key idea is to keep the top panel dominant and the lower panels supportive.
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.gridspec import GridSpec
x = np.arange(1, 13)
overall = np.array([100, 102, 98, 110, 115, 120, 118, 125, 130, 128, 135, 140])
seg_a = overall * 0.5 + np.random.normal(0, 2, len(x))
seg_b = overall * 0.3 + np.random.normal(0, 2, len(x))
seg_c = overall * 0.2 + np.random.normal(0, 2, len(x))
fig = plt.figure(figsize=(12, 8), constrained_layout=True)
gs = GridSpec(2, 2, figure=fig)
axmain = fig.addsubplot(gs[0, :])
ax_main.plot(x, overall, color=‘tab:blue‘)
axmain.settitle(‘Overall metric‘)
axmain.setxlabel(‘Month‘)
axmain.setylabel(‘Value‘)
axa = fig.addsubplot(gs[1, 0])
axa.plot(x, sega, color=‘tab:green‘)
axa.settitle(‘Segment A‘)
axb = fig.addsubplot(gs[1, 1])
axb.plot(x, segb, color=‘tab:orange‘)
axb.settitle(‘Segment B‘)
fig.suptitle(‘Summary with breakdowns‘)
plt.show()
I know this moves beyond subplots() alone, but the idea still applies: layout should reflect narrative hierarchy. subplots() gets you 80% of the way there. GridSpec handles the 20% of cases where the grid must be asymmetrical.
When to Avoid Subplots
Subplots are powerful, but they are not always the right tool. I avoid subplots when:
- The data series are on the same scale and can be compared in a single axes without confusion.
- The plots are unrelated and do not benefit from side-by-side comparison.
- The figure would be so dense that each panel becomes unreadable.
- I need interactive exploration where I want one plot to be large and responsive rather than a static grid.
For example, if I want to compare a baseline and a variant, I often prefer a single axes with two lines plus confidence intervals. That keeps the visual field focused and reduces repeated labels. A single plot also works better in slide decks where space is limited.
On the other hand, I reach for subplots when each panel needs a different plot type (line vs. histogram vs. bar). Mixing plot types in a single axes is usually a visual mess and dilutes the comparison. Subplots keep the semantics clean.
Practical Scenarios You Can Reuse
Here are a few scenarios where subplots() consistently pays off in real work.
Scenario 1: Monitoring a Service
I often plot latency, error rate, and throughput on the same figure for a single service. A 3×1 stack with shared x-axis makes the temporal relationship obvious.
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(7)
t = np.arange(0, 60)
latency = 100 + np.random.normal(0, 8, len(t))
error_rate = np.random.uniform(0.1, 0.6, len(t))
throughput = 500 + np.random.normal(0, 20, len(t))
fig, ax = plt.subplots(3, 1, sharex=True, figsize=(10, 7), constrained_layout=True)
ax[0].plot(t, latency, color=‘tab:blue‘)
ax[0].set_ylabel(‘Latency (ms)‘)
ax[0].set_title(‘Latency‘)
ax[1].plot(t, error_rate, color=‘tab:red‘)
ax[1].set_ylabel(‘Error rate (%)‘)
ax[1].set_title(‘Error rate‘)
ax[2].plot(t, throughput, color=‘tab:green‘)
ax[2].set_ylabel(‘Requests/s‘)
ax[2].set_xlabel(‘Minute‘)
ax[2].set_title(‘Throughput‘)
fig.suptitle(‘Service health at a glance‘)
plt.show()
Scenario 2: A/B Testing
For A/B tests, I like to show conversion rate and traffic volume side by side so that a spike in conversions can be checked against traffic changes.
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(1)
days = np.arange(1, 15)
conv_a = 0.12 + np.random.normal(0, 0.01, len(days))
conv_b = 0.13 + np.random.normal(0, 0.01, len(days))
traffic = 800 + np.random.normal(0, 50, len(days))
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
ax[0].plot(days, conv_a * 100, label=‘Variant A‘, color=‘tab:blue‘)
ax[0].plot(days, conv_b * 100, label=‘Variant B‘, color=‘tab:orange‘)
ax[0].set_title(‘Conversion rate‘)
ax[0].set_xlabel(‘Day‘)
ax[0].set_ylabel(‘Conversion (%)‘)
ax[0].legend()
ax[1].plot(days, traffic, color=‘tab:green‘)
ax[1].set_title(‘Traffic volume‘)
ax[1].set_xlabel(‘Day‘)
ax[1].set_ylabel(‘Visits‘)
fig.suptitle(‘A/B test context‘)
plt.show()
Scenario 3: Machine Learning Diagnostics
I often build a 2×2 grid: training loss, validation loss, confusion matrix, and distribution of prediction confidence. The grid makes it easy to see if a model is improving but also overfitting.
import numpy as np
import matplotlib.pyplot as plt
epochs = np.arange(1, 11)
train_loss = np.array([1.0, 0.82, 0.7, 0.58, 0.5, 0.44, 0.4, 0.36, 0.34, 0.31])
val_loss = np.array([1.05, 0.9, 0.78, 0.65, 0.6, 0.62, 0.63, 0.66, 0.68, 0.7])
conf = np.random.beta(2, 5, size=300)
fig, ax = plt.subplots(2, 2, figsize=(12, 8), constrained_layout=True)
ax[0, 0].plot(epochs, train_loss, label=‘Train‘, color=‘tab:blue‘)
ax[0, 0].plot(epochs, val_loss, label=‘Validation‘, color=‘tab:red‘)
ax[0, 0].set_title(‘Loss curves‘)
ax[0, 0].set_xlabel(‘Epoch‘)
ax[0, 0].set_ylabel(‘Loss‘)
ax[0, 0].legend()
Fake confusion matrix
cm = np.array([[52, 8], [12, 48]])
ax[0, 1].imshow(cm, cmap=‘Blues‘)
ax[0, 1].set_title(‘Confusion matrix‘)
ax[0, 1].set_xticks([0, 1])
ax[0, 1].set_yticks([0, 1])
ax[1, 0].hist(conf, bins=20, color=‘tab:green‘)
ax[1, 0].set_title(‘Prediction confidence‘)
ax[1, 0].set_xlabel(‘Confidence‘)
ax[1, 0].set_ylabel(‘Count‘)
ax[1, 1].axis(‘off‘)
ax[1, 1].text(0.1, 0.5, ‘Add notes\nabout model behavior‘, fontsize=12)
fig.suptitle(‘Model diagnostics‘)
plt.show()
This example also shows a trick I use: sometimes I leave a panel blank and use it for notes or summary text. It keeps the grid symmetric while giving me room for narrative.
Common Pitfalls and How I Avoid Them
Here are the mistakes I see most often when people move from single plots to subplots:
1) Assuming axes is always a list or array. It is not. Use squeeze=False or normalize with np.atleast_2d.
2) Mixing stateful and object-oriented calls. Once you have ax, commit to ax.plot() and friends. It prevents accidental plotting on the wrong axes.
3) Forgetting consistent limits when comparisons need them. If your goal is to compare magnitudes, set consistent ylim explicitly. Otherwise, auto-scaling can mislead.
4) Overloading titles. Each subplot does not need a full sentence title. I keep them short and move context to the figure title.
5) Ignoring label collisions. If your labels overlap, you lose trust. Use constrainedlayout, tightlayout, or manual spacing.
6) Using too many colors. Subplots already add visual complexity; keep color usage minimal and purposeful.
7) Hiding important ticks. If you share axes and use label_outer(), make sure the visible ticks still give enough context.
8) Forgetting to align units. If subplots compare similar metrics but with different units, the comparison becomes confusing. I standardize units across panels or add clear labels.
9) Not checking aspect ratios. A 2×2 grid on a narrow screen can squash plots. Adjust figsize or reduce grid density.
When I catch these early, I can fix them fast. When I ignore them, I get messy figures that I never want to share.
Performance Considerations in 2026
Matplotlib is still a workhorse. In most real-world notebooks, a 2×2 or 3×1 grid with line plots renders quickly enough to feel immediate. Dense scatter plots and heavy styling slow things down, especially when you add transparency or large markers. The rendering time for a few line plots is usually in the tens of milliseconds. Large scatter plots can push into the low hundreds of milliseconds depending on your hardware and backend.
Here are the practical knobs I use when performance becomes an issue:
- Reduce point count via downsampling for preview plots.
- Avoid alpha blending on huge point sets.
- Prefer lines over markers for large time series.
- Turn off antialiasing if the plot is very dense.
- Separate data preparation from plotting so you can cache arrays.
I also consider the target format. If you are exporting to a vector format like PDF or SVG, very dense plots can bloat file sizes. Raster formats like PNG handle dense scatter better, but you lose scalability. This is one reason to keep subplots focused: smaller, cleaner panels export faster and look better in reports.
Modern Tooling and AI-Assisted Workflows
In modern workflows, I often pair subplots with AI-assisted helpers. The key is that subplots() creates a stable skeleton. Once you know the grid shape and axes names, you can let a code assistant generate boilerplate or standard labels without risk. For example, I might ask for a 2×3 layout and let the assistant stub out the axes, then I fill in the plotting logic and data-specific details.
The important part is that the axes naming stays consistent. If you always assign fig, ax = plt.subplots(...), and you always index ax[row, col], you can move faster without introducing subtle bugs.
I also use small utilities that enforce layout conventions, like a helper that applies consistent fonts, grid lines, and title size across subplots. These utilities keep the visual style coherent across multiple figures in a report.
A Comparison Table: Subplots vs. Alternatives
Sometimes it helps to compare approaches so you can choose intentionally.
Best for
My rule of thumb
—
—
Same units, direct comparison
Use when fewer than 4 series and scales match
Time-based comparisons
Use for multi-metric dashboards
Asymmetric layouts
Use when one panel needs emphasis
Independent stories
Use when plots are unrelatedSubplots are not a universal solution. They are a reliable default when you have multiple plots that belong together and need to be read as a set.
Troubleshooting Layout Issues
When a subplot grid looks wrong, I step through a short checklist:
- Check figure size. If it is too small, everything else fails.
- Verify that labels and titles are short enough for the grid.
- Turn on
constrained_layout=Trueand see if the issue resolves. - If constrained layout fails, try
tight_layout(). - If that still fails, use
subplots_adjust()with explicitwspaceandhspace. - As a last resort, remove a panel or split into two figures.
This is not glamorous, but it saves time. Layout problems often look like data problems at first glance, which is why I treat layout as an explicit step in my plotting workflow.
A Small Pattern Library I Reuse
I keep a few layout patterns in my head because they cover most needs:
- 1×2 for side-by-side comparisons
- 2×1 for stacked time series
- 2×2 for dashboards and four-metric summaries
- 3×1 for latency, error, throughput style monitoring
- 2×3 for cohort analysis or experiment grids
Once you know these patterns, you can move faster. The decision becomes: which pattern matches the comparison I want to show? That is more useful than memorizing parameters.
Mistakes I See in Teams (and How to Fix Them)
Teams that are new to Matplotlib often fall into two patterns: too much manual layout tweaking or too much reliance on defaults. I try to stay in the middle.
- If the plot looks off and the code has three or four
subplotsadjustcalls, it is a smell. Useconstrainedlayoutfirst. - If the plot is tiny and overlapping labels, do not fight it. Increase
figsizeor reduce the grid. - If you rely on global
pltstate, your plots will break when reused. Make the axes explicit. - If you mix data preparation and plotting, re-runs become slow. Separate and cache data.
These are not hard rules, but they are patterns I see in code reviews. Fixing them early makes the rest of the plotting work smoother.
A Mini Checklist Before You Share a Figure
Before I send a report or include a figure in a deck, I do a quick pass:
- Titles: short and descriptive
- Axes labels: consistent units
- Scales: shared where comparison requires it
- Legend: only where needed
- Figure size: readable on target medium
- Gridlines: subtle, not dominant
This checklist helps me spot layout issues that I would miss if I only looked at the data.
When You Should Skip Subplots Entirely
This is worth repeating: sometimes subplots are the wrong choice. If your comparison is about a small number of series on the same scale, a single axes is more direct. If your story is sequential, two separate figures might be clearer. If you are building an interactive dashboard, you might prefer a dedicated dashboarding tool rather than static subplots.
Subplots are great for static reports and quick comparisons. They are less ideal when interaction is the core requirement.
A Closing Plan You Can Reuse
I like to finish by grounding the technique in a simple plan you can use the next time you work with multi-plot figures. First, decide the comparison you want your reader to make. That choice drives your grid shape. Next, sketch the grid on paper and assign each subplot a role. Then build the figure using subplots() and plot one panel at a time, checking titles and labels as you go. Finally, use constrainedlayout or tightlayout to clean up spacing, and review the figure as if you were the reader seeing it for the first time.
If you do that consistently, your plots stop being a collection of charts and start becoming a narrative. That is what subplots() is really for: not just drawing multiple graphs, but arranging a clear, honest story that your eyes can follow without effort.


