Figure size, margins and the space between panels - the part that decides whether a chart looks finished.
Overview
figsize is an aspect decision
Layout is the last step most people take and the first step they should. A chart whose axes are too small, whose labels overlap, or whose panels are unevenly spaced is harder to read no matter how good the data is.
matplotlib gives you full control over the size of the figure, the margins around the axes, the spacing between subplots, the position of text outside the axes, and the aspect ratio of the data. That control is powerful and, by default, mostly manual. tight_layout and constrained_layout automate the common cases, but understanding what they do helps when they do not do enough.
This page covers figure size, margins, subplot spacing, elements outside the axes, data aspect ratio, coordinate systems, manual axes placement, and the settings that make a layout survive changes in data or figure size. It also covers sizing for a specific output: a slide, a paper column, a web page.
The most common layout problem is overlapping text. Long tick labels, axis labels and titles collide when the figure is small or the panels are many. fig.tight_layout() adjusts padding to fit everything. constrained_layout=True in the subplots call does the same thing but recalculates on every draw, which handles cases where artists are added after the layout is set.
figsize=(w, h) in inches does two things: it sets how large the figure is, and it sets the shape.
The shape matters more than the size. The size is often adjusted at display time anyway.
Time series want width. A hundred and fifty points in three inches is 50 per inch. The shape of the series is compressed into noise. The same data in nine inches is readable. Wide-and-short. Something like 4:1. Is a good default for a single time series.
Two-var scatters want to be square. Neither axis is visually privileged and a link isn't exaggerated by the aspect ratio.
Ranked bar charts want height scaled to the number of bars. Twenty groups in three inches gives bars a few pixels apart. The figure should grow with the data.
figsize interacts with font size: text is a fixed physical size. A small figure has proportionally larger text. That is why shrinking a figure to fit a slide makes the labels look huge. Why the fix is to set both together.
Worth knowing
figsize is an aspect-ratio decision: time series want width, two-variable scatters want to be square.
matplotlib adds a 5% margin each side; margins(x=0) is the usual fix for a time series that should reach the edge.
hspace/wspace are fractions of the panel size, and are what the automatic layout managers set for you.
A legend placed outside the axes is drawn outside the figure too unless room is made with subplots_adjust or constrained_layout.
set_aspect("equal") ties the two scales so a circle is round — needed for maps and geometry, wasteful elsewhere.
A finished figure is a handful of deliberate choices: size, limits, margins, ticks, grid, spines, and labels where the reader is looking.
Layout and Spacing
Figure size, margins and the space between panels.
figsize is the aspect ratio decision
A wide figure spreads time; a square one compares two variables.
example_01.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(0)
t = np.arange(150)
y = rng.normal(0, 1, 150).cumsum()
fig, ax = plt.subplots(figsize=(3.5, 3))
ax.plot(t, y)
ax.set_title("square: crowded")
fig2, ax2 = plt.subplots(figsize=(9, 2.6))
ax2.plot(t, y)
ax2.set_title("wide: the shape is readable")
print("150 points in 3.5 inches is 43 per inch; in 9 inches it is 17.")
print()
print("Time series want width. A scatter comparing two variables wants")
print("to be square, so neither axis is visually privileged.")
print("A ranked bar chart wants height proportional to the number of")
print("bars, not a fixed size.")
Output
Margins around the data
The padding matplotlib adds, and when to remove it.
example_02.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 50)
y = np.sin(x)
fig, (a, b, c) = plt.subplots(1, 3, figsize=(10, 2.6))
a.plot(x, y); a.set_title("default margins")
b.plot(x, y); b.margins(0); b.set_title("margins(0)")
c.plot(x, y); c.margins(x=0, y=0.3); c.set_title("margins(x=0, y=0.3)")
print("default x margin:", plt.rcParams["axes.xmargin"])
print("default y margin:", plt.rcParams["axes.ymargin"])
print()
print("5% each side. It stops points sitting on the frame, and it")
print("means a line that should reach the edge - a filled area, a")
print("time series ending today - stops short of it.")
print()
print("margins(x=0) is the usual fix for a time series.")
Output
Space between panels
hspace and wspace, in units of the panel size.
example_03.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(1)
for hs, ws, title in [(0.05, 0.05, "cramped"), (0.5, 0.4, "roomy")]:
fig, axes = plt.subplots(2, 2, figsize=(6, 3.4))
for ax in axes.flat:
ax.plot(rng.random(20))
ax.set_xlabel("x"); ax.set_ylabel("y")
fig.subplots_adjust(hspace=hs, wspace=ws)
fig.suptitle(title)
print("hspace and wspace are fractions of the average panel size, so")
print("0.4 means 40% of a panel's height as a gap.")
print()
print("They are what tight_layout and constrained_layout set for you.")
print("Setting them by hand is for when you want a specific look, or")
print("when automatic layout fights an annotation you placed.")
Output
fig.subplots_adjust(hspace=..., wspace=...) sets the gaps as fractions of the mean panel size. 0.4 means a gap of 40% of a panel's height.
It also takes left, right, top and bottom as figure fractions. This Is how you reserve space at an edge.
These are what tight_layout and constrained_layout find for you. Setting them by hand is for when you want a exact look, or when the auto layout is fighting a thing you placed manually.
The two auto options differ: tight_layout() runs once after drawing. constrained_layout=True is set at creation and keeps adjusting as artists are added, handling colorbars and suptitles better.
Making room for something outside
A legend or a colorbar beside the axes needs space reserved.
example_04.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 60)
fig, (a, b) = plt.subplots(1, 2, figsize=(10, 3))
for k in range(3):
a.plot(x, np.sin(x + k), label="series %d" % k)
a.legend(loc="upper left", bbox_to_anchor=(1.02, 1))
a.set_title("legend outside, no room made")
for k in range(3):
b.plot(x, np.sin(x + k), label="series %d" % k)
b.legend(loc="upper left", bbox_to_anchor=(1.02, 1))
b.set_title("room made")
fig.subplots_adjust(right=0.78)
print("A legend placed outside the axes is drawn outside the FIGURE")
print("too, unless room is made for it.")
print()
print("Three ways: subplots_adjust(right=...), constrained_layout=True,")
print("or bbox_inches='tight' when saving - which expands the file")
print("rather than shrinking the axes.")
Output
Aspect ratio of the data
When one unit of x must equal one unit of y.
example_05.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
theta = np.linspace(0, 2 * np.pi, 200)
fig, (a, b) = plt.subplots(1, 2, figsize=(9, 3))
a.plot(np.cos(theta), np.sin(theta))
a.set_title("a circle, drawn as an ellipse")
b.plot(np.cos(theta), np.sin(theta))
b.set_aspect("equal")
b.set_title('set_aspect("equal")')
print("The same unit circle. The left one is an ellipse because the")
print("axes box is wider than it is tall and the limits are the same.")
print()
print("set_aspect('equal') ties the two scales together, which matters")
print("for maps, geometry, and anything where a shape means something.")
print("It is wrong for most other charts - it wastes space.")
Output
A finished figure
Every layout decision in one place.
example_06.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(2)
months = np.arange(1, 13)
series = {"north": rng.normal(0, 1, 12).cumsum() + 20,
"south": rng.normal(0, 1, 12).cumsum() + 24}
fig, ax = plt.subplots(figsize=(7.5, 3.6), constrained_layout=True)
for name, y in series.items():
ax.plot(months, y, marker="o", linewidth=2, label=name)
ax.text(months[-1] + 0.15, y[-1], name, va="center", fontsize=9)
ax.set_xlim(0.6, 13.2)
ax.margins(y=0.15)
ax.set_title("Both regions grew, north faster after June",
loc="left", fontsize=12, fontweight="bold")
ax.set_xlabel("Month of 2024")
ax.set_ylabel("Index (Jan = 20)")
ax.set_xticks(months)
ax.grid(True, axis="y", alpha=0.3)
ax.set_axisbelow(True)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
print("figsize for the aspect, xlim to leave room for the end labels,")
print("margins for breathing space, constrained_layout for the rest.")
print("No legend, because the lines are labelled where they end.")
Output
Margins
matplotlib leaves a 5% margin beyond the data at each end, controlled by axes.xmargin and axes.ymargin.
It exists so points don't sit just on the frame, and it's often right.
It's wrong when the data should reach the edge. A filled area, a time series ending at today's date, an image. ax.margins(x=0) drops it on one axis, and ax.margins(0) on both.
ax.margins(y=0.15) grows it, which is how you leave room for labels above the highest point without hard-coding limits that break when the data changes.
Things outside the axes
A legend positioned with bbox_to_anchor outside the axes is drawn outside the figure as well, and is simply cut off.
Three fixes:
fig.subplots_adjust(right=0.78) shrinks the axes to leave room. Predictable, and you choose the number.
constrained_layout=True works it out, including for colorbars.
bbox_inches="tight" at save time expands the saved image to include all. Note this makes the file larger than figsize implies, rather than shrinking the axes. A other result from the other two.
The same applies to long tick labels and axis labels. This Is why a rotated date axis so often looks cut off.
Data aspect ratio
ax.set_aspect("equal") makes one unit of x occupy the same distance as one unit of y.
Without it, a circle plotted as cos/sin comes out an ellipse. The axes box is wider than it's tall while the limits are the same.
It's needed for anything where shape carries meaning. Maps, geometry, images, physical layouts. It's wrong for most statistical charts. Here The two axes have unrelated units and forcing them equal wastes space.
ax.set_aspect(2) sets a exact ratio, and adjustable="datalim" changes the limits rather than the box to achieve it.
Putting it together
The last editor is a finished chart. Each line in it's one of the choices from this track. An aspect ratio suited to the data, limits that leave room for end labels, margins for breathing space, ticks at real spots, a faint grid below the data, two spines removed, direct labels instead of a legend, and a title that states the finding.
None of it's complicated. It's a dozen lines, applied on purpose. It's the gap between a default chart and one that looks like someone made it.
Figure versus axes coordinates
Two coord systems place things on a figure. Mixing them up is a common source of misplaced elements.
Figure coords run 0 to 1 across the whole figure. fig.text, fig.add_axes, fig.legend use them.
Axes coords run 0 to 1 across one axes. ax.text(..., transform=ax.transAxes) uses them.
A note that belongs to the whole figure. A source line, a caption. Goes in figure coords and stays put when panels change. A note about one panel goes in that panel's axes coords.
fig.subplots_adjust also takes figure coords for left, right, top and bottom. This Is how you reserve a strip at an edge.
Adding an axes anywhere
fig.add_axes([left, bottom, width, height]) places an axes at an exact spot in figure coords, outside the grid fully.
Two uses come up: an inset showing a zoomed region. A colorbar positioned just rather than stolen from a panel.
ax.inset_axes([x, y, w, h]) does the same in the parent's coords. This Is easier for an inset because the spot is scaled to the panel it belongs to.
ax.indicate_inset_zoom(inset_ax) draws the connecting lines between the region and the inset. This Is what makes the link readable.
Spacing that reads
A few rules give figures that look seen as rather than default.
Leave more space between groups than within them. A grid of panels with a wspace smaller than the margin around the grid reads as a unit.
Keep the left margin wide enough for the y label at its final font size. This Is what constrained_layout does and hand-tuning often gets wrong when the font changes.
Give a suptitle room, or it collides with the top row's titles. fig.suptitle(..., y=1.02) with tight_layout is a common fix and is fragile. constrained_layout handles it properly.
Don't centre a title over a grid that has a colorbar on one side. The visual centre and the geometric centre differ.
Sizing for a destination
The figure should be sized for where it will be seen.
A slide is wide and viewed from a distance: fewer elements, larger text, figsize around 10×5.6 for 16:9.
A doc column is narrow: around 3.5 inches wide for a two-column paper. Text sized so that the figure needs no scaling. Scaling a figure in a doc is what makes its labels the wrong size scaled to the body text.
A web page is var. So A raster at 2× the display size and a max-width in CSS is the usual approach.
The mistake is drawing at a default size and scaling later. This Changes the text size scaled to all else. Sizing at the destination's dims and choosing the font so is the fix.
The last pass
Before a figure is finished, three checks that are all layout:
Is anything cut off at the edges? Save it and look at the file, not the screen.
Is the text a sensible size scaled to the figure at its final display size?
Is there enough space that nothing touches anything else. Labels to spines, title to panels, legend to data?
None of these change what the chart says. All of them change whether it looks like someone finished it.
Insets
An inset shows a zoomed region or a small companion chart inside the main axes.
The spot is in the parent's axes coords. So It moves with the panel.
indicate_inset_zoom draws the rectangle on the main axes and the connecting lines. This Is what makes the link legible. An inset without it's a floating chart the reader has to read.
Insets suit a long series with an notable short window. A scatter with a dense cluster worth magnifying. They don't suit anything the reader needs to compare just with the main axes, because the scales differ.
Layout that survives regeneration
Hand-tuned spacing breaks when the data changes: a longer label, an extra group, a bigger number needing more room.
Three habits keep a layout solid.
**Prefer constrained_layout** over hand-set subplots_adjust, because it recomputes rather than remembering.
Size the figure from the data. figsize=(7, 0.35 * len(groups) + 1) for a flat bar chart grows with the number of bars.
**Use bbox_inches="tight" when saving**, so anything that grew is still included.
A figure made weekly will meet all three problems eventually. The option is adjusting the numbers each time a thing changes.
Text size and the destination
The most common layout error is a figure drawn at one size and displayed at another.
Text in matplotlib is given in points, a physical unit. A 10-point label on a 4-inch figure is a quarter of an inch. This Is a large fraction of the width. The same label on a 10-inch figure is the same quarter inch, and now looks small.
So scaling a figure after the fact changes the link between the text and all else. Which is why a chart shrunk to fit a doc column has labels that are suddenly too large. One enlarged for a slide has labels that are too small.
The fix is to draw at the final size. For a two-column paper, that means a figure about 3.4 inches wide with fonts chosen to be readable at that size. For a slide, something like 10 by 5.6 with fonts around 14 points.
figsize and font.size are one choice, not two.
Layout and Spacing, in summary
figsize is an aspect-ratio choice: width for time series, square for scatters, height scaled to the number of bars.
Margins default to 5% each side and should be removed when the data is meant to reach the edge.
hspace/wspace are fractions of panel size, and are what the layout managers find for you.
Anything outside the axes — a legend, long labels — needs room reserved, or it's cut off in the saved file.
set_aspect("equal") is needed where shape carries meaning and wasteful elsewhere.
And a figure should be drawn at the size it will be displayed. Text is a physical size and scaling later changes all about the balance.
A closing note on Layout and Spacing
Layout is the part of charting that gets the least focus and is most visible in the result.
A chart with the right data, the right type and the wrong spacing looks unfinished. Readers register that before they read anything. Labels touching the frame, a title colliding with a panel, a legend crowding the data, text at the wrong size for the display. All are cheap to fix and all cost credibility.
The methods are few: pick a figure size for the destination, let constrained_layout handle the spacing, reserve room for anything outside the axes. Check the saved file rather than the screen.
Doing that always is most of the gap between charts that look made and charts that look exported.
Questions people ask about Layout and Spacing
Why are my labels cut off or overlapping? The default spacing does not account for label size. Use layout='constrained' or fig.tight_layout().
What is the difference between tight_layout and constrained layout?tight_layout adjusts once when called. Constrained layout recalculates as you add elements, and handles colorbars and legends better.
How do I set the figure size? Pass figsize=(width, height) in inches to plt.figure or plt.subplots.
How do I move a legend outside the axes? Use bbox_to_anchor with loc, then save with bbox_inches='tight' so it is not cropped.
How do I control the gap between panels? Use fig.subplots_adjust(wspace=..., hspace=...), or GridSpec spacing arguments.
How do I set the aspect ratio of one plot? Use ax.set_aspect('equal') for equal units, or ax.set_box_aspect(0.5) for a fixed box shape.
Layout and Spacing: the short version
Layout gets the least focus and is the most visible in the result.
Size for the destination, let a layout manager handle the spacing, reserve room for anything outside the axes. Look at the saved file. That is most of the gap between charts that look made and charts that look exported.
Check yourself
0 of 4
Answer without scrolling back up.
What shape suits a single time series?
Compressing 150 points into three inches turns the shape into noise. Two-variable scatters want to be square instead.
Why does a filled area chart often need `ax.margins(x=0)`?
The margin exists so points do not sit on the frame, which is usually right and wrong for anything meant to reach the edge.
What happens to a legend placed outside the axes with no other change?
Fix with subplots_adjust, constrained_layout, or bbox_inches='tight' at save time - which expands the file rather than shrinking the axes.
When do you need `set_aspect('equal')`?
Without it a circle comes out an ellipse. For statistical charts with unrelated units it just wastes space.
Cheat sheet
Layout and Spacing
Set figure size, margins and subplot spacing in matplotlib. Use constrained_layout to stop labels and titles from overlapping automatically.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.