pythonforbio.
[WASM idle]
Plotting05/14

Figure and Axes

Starting sandbox…
Beginnerlesson

Figure and Axes

In one line: a Figure is the canvas, an Axes is one panel on it — and "Axes" does not mean "axis".

The container hierarchy

Figure                    the whole image; owns size, DPI, saving
 ├─ Axes                  one plotting panel
 │   ├─ XAxis / YAxis     the actual axes: ticks, labels, scale
 │   ├─ Line2D            plotted lines
 │   ├─ Collection        scatter points, patches in bulk
 │   ├─ Text              titles, labels, annotations
 │   └─ Spines            the four border lines
 ├─ Axes                  another panel
 ├─ Legend
 └─ SuptitleText

The naming is genuinely unfortunate. Axes = a panel. A 2×2 grid contains four Axes. The x-axis of one panel is ax.xaxis. Once you accept it, everything else follows.

Creating them

fig, ax = plt.subplots()                                    # one panel
fig, ax = plt.subplots(figsize=(6, 4))                      # size in INCHES
fig, axes = plt.subplots(2, 3, figsize=(12, 6))             # a 2×3 grid → ndarray
fig, axes = plt.subplots(2, 2, sharex=True, sharey=True)
fig, (ax1, ax2) = plt.subplots(1, 2)                        # unpack
fig, axes = plt.subplots(2, 2, layout="constrained")         # auto-fix overlaps

plt.subplots() returns (Figure, Axes) for a single panel and (Figure, ndarray of Axes) for a grid. axes.flat iterates a grid regardless of its shape:

for ax, gene in zip(axes.flat, genes):
    ax.plot(...)
    ax.set_title(gene)

Figure size and DPI

fig, ax = plt.subplots(figsize=(6, 4), dpi=100)

figsize is in inches. Pixel dimensions are figsize × dpi. For publication, set the figure to the journal's column width and let the DPI handle resolution:

Target figsize
Single journal column (3.5, 2.5)
Double column (7, 4)
Full page (7, 9)
Slide (10, 5.6) — 16:9

Sizing the figure correctly at creation time is the difference between crisp 8 pt labels and a figure that was shrunk to 40% in the manuscript and is now illegible. Never resize a matplotlib figure in a word processor. Set figsize and font sizes so it is correct at final print size.

The Axes method surface

# plotting
ax.plot(x, y); ax.scatter(x, y); ax.bar(x, h); ax.hist(v, bins=50)
ax.errorbar(x, y, yerr=e); ax.fill_between(x, lo, hi)
ax.imshow(matrix); ax.boxplot(data); ax.violinplot(data)

# labels and limits
ax.set_xlabel("..."); ax.set_ylabel("..."); ax.set_title("...")
ax.set_xlim(0, 100); ax.set_ylim(bottom=0)
ax.set(xlabel="...", ylabel="...", title="...", xlim=(0, 100))     # all at once

# scales
ax.set_xscale("log"); ax.set_yscale("log")
ax.set_yscale("symlog", linthresh=1)      # log that handles zero and negatives

# reference lines
ax.axhline(0, ls="--", lw=1, c="grey")
ax.axvline(x=1); ax.axvspan(10, 20, alpha=0.2)

# ticks
ax.set_xticks([0, 1, 2]); ax.set_xticklabels(["a", "b", "c"])
ax.tick_params(axis="x", rotation=45, labelsize=8)

# appearance
ax.grid(True, alpha=0.3)
ax.legend(frameon=False, loc="upper right")
ax.spines[["top", "right"]].set_visible(False)      # the "despine" idiom

ax.set(...) is worth adopting — one call instead of four.

Figure-level methods

fig.suptitle("Overall title", fontsize=14)
fig.savefig("out.pdf", bbox_inches="tight", dpi=300)
fig.tight_layout()                       # legacy layout fixer
fig.colorbar(mappable, ax=ax, label="log2 expression")
fig.legend(...)                          # one legend for the whole figure
fig.align_ylabels(axes)                  # line up y-labels across panels
plt.close(fig)                           # free memory — essential in loops

Sharing axes

fig, axes = plt.subplots(2, 2, sharex=True, sharey=True)
fig, axes = plt.subplots(1, 3, sharey="row")

Shared axes mean the same scale in every panel, which is what makes small multiples comparable. It also removes the redundant inner tick labels automatically.

If panels do not share a scale, say so prominently — a reader will assume they do, and a 10× difference in y-range between adjacent panels is genuinely misleading.

Reaching into a seaborn plot

Axes-level seaborn functions return the Axes; figure-level ones return a grid object:

ax = sns.scatterplot(data=df, x="a", y="b")       # ax is a matplotlib Axes
ax.axhline(0)

g = sns.relplot(data=df, x="a", y="b", col="gene")
g.figure                       # the Figure
g.axes                         # ndarray of Axes
g.axes_dict["TP53"]            # by facet value
g.axes.flat[0].axhline(0)

Everything in this note applies to seaborn output. See Figure-level vs Axes-level.

Twin axes

ax2 = ax.twinx()          # shares x, independent y
ax2 = ax.twiny()

Useful for plotting coverage and GC content on one panel. Also a well-known way to mislead — two arbitrary y-scales can make any two series look correlated. Use it sparingly and label both axes unambiguously.

Common mistakes

  • Confusing Axes with axis.
  • Resizing after saving — the save captured the state at that moment.
  • Not closing figures in a loop → "More than 20 figures have been opened" and growing memory. Always plt.close(fig).
  • Setting figsize after creation. fig.set_size_inches() works but changes relative font sizes.
  • Assuming panels share a scale when they do not.
  • axes being a 2-D array and indexing it as 1-D. Use axes.flat.
  • plt.subplots(2) vs plt.subplots(2, 2) — the first gives 2 stacked panels, not 4.

See also

pyplot vs Object-Oriented API · Subplots and Layout · Matplotlib Artists · Saving Figures · Axes Styling and Annotation · Figure-level vs Axes-level

lesson example

No output yet — run the code to populate this drawer.