Layout and Spacing

Figure size, margins and the space between panels - the part that decides whether a chart looks finished.

Overview

figsize is an aspect decision

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, because the size is usually adjusted at display time anyway.

Time series want width. A hundred and fifty points in three inches is 50 per inch, and 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-variable scatters want to be square, so neither axis is visually privileged and a correlation is not exaggerated by the aspect ratio.

Ranked bar charts want height proportional to the number of bars. Twenty categories 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, so a small figure has proportionally larger text. That is why shrinking a figure to fit a slide makes the labels look enormous, and 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
Output

Margins around the data

The padding matplotlib adds, and when to remove it.

example_02.pymatplotlib
Output

Space between panels

hspace and wspace, in units of the panel size.

example_03.pymatplotlib
Output

fig.subplots_adjust(hspace=..., wspace=...) sets the gaps as fractions of the average panel size, so 0.4 means a gap of 40% of a panel's height.

It also takes left, right, top and bottom as figure fractions, which is how you reserve space at an edge.

These are what tight_layout and constrained_layout compute for you. Setting them by hand is for when you want a specific look, or when the automatic layout is fighting something you placed manually.

The two automatic 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
Output

Aspect ratio of the data

When one unit of x must equal one unit of y.

example_05.pymatplotlib
Output

A finished figure

Every layout decision in one place.

example_06.pymatplotlib
Output

Margins

matplotlib leaves a 5% margin beyond the data at each end, controlled by axes.xmargin and axes.ymargin.

It exists so points do not sit exactly on the frame, and it is usually right.

It is 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) removes it on one axis, and ax.margins(0) on both.

ax.margins(y=0.15) increases 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 everything. Note this makes the file larger than figsize implies, rather than shrinking the axes — a different result from the other two.

The same applies to long tick labels and axis labels, which 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, because the axes box is wider than it is tall while the limits are the same.

It is required for anything where shape carries meaning: maps, geometry, images, physical layouts. It is wrong for most statistical charts, where the two axes have unrelated units and forcing them equal wastes space.

ax.set_aspect(2) sets a specific ratio, and adjustable="datalim" changes the limits rather than the box to achieve it.

Putting it together

The last editor is a finished chart, and every line in it is one of the decisions from this track: an aspect ratio suited to the data, limits that leave room for end labels, margins for breathing space, ticks at meaningful positions, 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 is complicated. It is a dozen lines, applied deliberately, and it is the difference between a default chart and one that looks like someone made it.

Figure versus axes coordinates

Two coordinate systems place things on a figure, and mixing them up is a common source of misplaced elements.

Figure coordinates run 0 to 1 across the whole figure. fig.text, fig.add_axes, fig.legend use them.

Axes coordinates 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 coordinates and stays put when panels change. A note about one panel goes in that panel's axes coordinates.

fig.subplots_adjust also takes figure coordinates for left, right, top and bottom, which 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 position in figure coordinates, outside the grid entirely.

Two uses come up: an inset showing a zoomed region, and a colorbar positioned precisely rather than stolen from a panel.

ax.inset_axes([x, y, w, h]) does the same in the parent's coordinates, which is easier for an inset because the position is relative to the panel it belongs to.

ax.indicate_inset_zoom(inset_ax) draws the connecting lines between the region and the inset, which is what makes the relationship readable.

Spacing that reads

A few conventions produce figures that look considered 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, which is what constrained_layout does and hand-tuning frequently 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.

Do not centre a title over a grid that has a colorbar on one side, because 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 document column is narrow: around 3.5 inches wide for a two-column paper, and text sized so that the figure needs no scaling — scaling a figure in a document is what makes its labels the wrong size relative to the body text.

A web page is variable, 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 afterwards, which changes the text size relative to everything else. Sizing at the destination's dimensions and choosing the font accordingly 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 relative 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, and 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.

inset = ax.inset_axes([0.6, 0.55, 0.35, 0.35])
inset.plot(x, y)
inset.set_xlim(a, b)
ax.indicate_inset_zoom(inset, edgecolor="0.5")

The position is in the parent's axes coordinates, so it moves with the panel.

indicate_inset_zoom draws the rectangle on the main axes and the connecting lines, which is what makes the relationship legible — an inset without it is a floating chart the reader has to interpret.

Insets suit a long series with an interesting short window, and a scatter with a dense cluster worth magnifying. They do not suit anything the reader needs to compare precisely 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 category, a bigger number needing more room.

Three habits keep a layout robust.

Prefer constrained_layout over hand-set subplots_adjust, because it recomputes rather than remembering.

Size the figure from the datafigsize=(7, 0.35 * len(categories) + 1) for a horizontal bar chart grows with the number of bars.

Use bbox_inches="tight" when saving, so anything that grew is still included.

A figure generated weekly will meet all three problems eventually, and the alternative is adjusting the numbers each time something 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 specified in points, a physical unit. A 10-point label on a 4-inch figure is a quarter of an inch, which is a substantial 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 relationship between the text and everything else — which is why a chart shrunk to fit a document column has labels that are suddenly too large, and 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 decision, not two.

In summary

figsize is an aspect-ratio decision: width for time series, square for scatters, height proportional 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 compute for you.

Anything outside the axes — a legend, long labels — needs room reserved, or it is cut off in the saved file.

set_aspect("equal") is required where shape carries meaning and wasteful elsewhere.

And a figure should be drawn at the size it will be displayed, because text is a physical size and scaling afterwards changes everything about the balance.

A closing note

Layout is the part of charting that gets the least attention and is most visible in the result.

A chart with the right data, the right type and the wrong spacing looks unfinished, and 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 mechanisms are few: pick a figure size for the destination, let constrained_layout handle the spacing, reserve room for anything outside the axes, and check the saved file rather than the screen.

Doing that consistently is most of the difference between charts that look produced and charts that look exported.

The short version

Layout gets the least attention 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, and look at the saved file. That is most of the difference between charts that look produced and charts that look exported.

Check yourself

0 of 4

Answer without scrolling back up.

  1. What shape suits a single time series?

  2. Why does a filled area chart often need `ax.margins(x=0)`?

  3. What happens to a legend placed outside the axes with no other change?

  4. When do you need `set_aspect('equal')`?

Cheat sheet

Layout and Spacing

Time series want width. A hundred and fifty points in three inches is 50 per inch, and 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.

MATPLOTLIB · vizlearn.in/matplotlib/layout_and_spacing.html

About the author

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.