Saving Figures
Saving Figures
Core Principle: Publication-quality figures require vector formats (PDF/SVG) with embedded TrueType fonts (
pdf.fonttype=42) and rasterized dense point layers for performance, alongside 300+ DPI PNG exports for web distribution.
The call
fig.savefig("figure.pdf", bbox_inches="tight")
fig.savefig("figure.png", dpi=300, bbox_inches="tight")
fig.savefig("figure.svg")
fig.savefig("figure.pdf", bbox_inches="tight", transparent=True)
plt.close(fig)
bbox_inches="tight" crops whitespace and: importantly: rescues labels that would otherwise be clipped off the edge.
fig.savefig, not plt.savefig, so you know which figure you saved. See pyplot vs Object-Oriented API.
Format choice
| Format | Use for | Notes |
|---|---|---|
| journals, LaTeX | vector, embeds fonts, universal | |
| SVG | web, editing in Illustrator/Inkscape | vector, XML |
| EPS | legacy journals | vector, no transparency |
| PNG | slides, notebooks, GitHub | raster; set dpi |
| TIFF | some journals demand it | raster, large |
| JPG | never for plots | lossy compression creates artefacts on sharp lines |
Most journals want vector for line art (300–600 DPI equivalent) and accept 300 DPI raster for photographs and dense scatters.
The font problem
This code completed without producing output.
By default matplotlib embeds Type 3 fonts, which many PDF editors cannot edit and some journals reject. With fonttype = 42 your labels remain selectable, searchable, and editable in Illustrator: which matters because the last round of figure revision almost always happens there.
Set this once at the top of every plotting script. It costs nothing and saves a resubmission.
Rasterizing selected elements
ax.scatter(x, y, s=1, alpha=0.3, rasterized=True)
fig.savefig("volcano.pdf", dpi=300)
A vector PDF stores every one of your 500,000 scatter points as a separate object. The file becomes hundreds of megabytes and crashes Illustrator and some PDF viewers.
rasterized=True renders that Artist as pixels while keeping axes, ticks and text as vectors. You get a small file with sharp, editable text and a picture-quality point cloud. Essential for volcano plots, Manhattan plots, and single-cell embeddings.
fig.savefig(..., dpi=300) controls the resolution of the rasterized parts even in a vector file.
Sizing for publication
Get this right at creation, not afterwards:
This code completed without producing output.
| Target | Width (inches) | Width (mm) |
|---|---|---|
| Single column (most journals) | 3.3–3.5 | 85–89 |
| 1.5 column | 5.0–5.5 | 127–140 |
| Double column | 6.9–7.2 | 175–183 |
| Nature single | 3.5 | 89 |
| Nature double | 7.2 | 183 |
Never scale a figure in the manuscript. If you draw at 10 inches wide with 10 pt fonts and the journal shrinks it to 3.5 inches, your fonts become 3.5 pt and are illegible. Draw at final size and set fonts to what you want in print (7–9 pt for labels is typical).
Check by printing at 100% and reading it.
A reusable save function
This code completed without producing output.
Saving both PDF (for the manuscript) and PNG (for the slide deck, the Slack message, the lab meeting) every time removes a whole category of "can you send me that as a PNG" round-trips.
Memory in loops
for gene in genes:
fig, ax = plt.subplots()
ax.plot(...)
fig.savefig(f"plots/{gene}.png", dpi=150)
plt.close(fig) # ← without this, memory grows until the job dies
Matplotlib keeps every unclosed figure alive. Past 20 you get a warning; past a few thousand you get an OOM kill. plt.close(fig) is not optional in a loop.
Reproducible metadata
fig.savefig("fig.pdf", metadata={
"Creator": "analysis.py",
"Title": "Figure 2",
"Subject": f"commit {git_sha}, seed {SEED}",
})
Embedding the git commit and random seed in the file means that in eighteen months, when a reviewer asks how a figure was made, the figure itself tells you.
Transparent backgrounds
This code completed without producing output.
Good for slides over a coloured background. Bad if you also drew white text or white-edged markers: they vanish.
Common mistakes
- Default
fonttypeproducing uneditable Type 3 fonts. - No
bbox_inches="tight", clipping the y-label. - Not rasterizing a 500k-point scatter, producing a 200 MB PDF.
- Not closing figures in a loop.
- Drawing at the wrong size and scaling later.
- JPEG for plots.
- PNG at default 100 DPI for a journal.
- Saving before drawing is finished:
savefigcaptures the current state. plt.savefig()with several figures open, saving the wrong one.- Forgetting the PNG version and being asked for it later.
See also
Figure and Axes · rcParams and Style Sheets · Matplotlib Artists · Subplots and Layout · Applied - Reproducible Environment
Exercise
Write save_probe(width_in, height_in, dpi), reading two floats and an integer
from one input line. Close every open figure first, then create a figure at the
requested size and DPI, plot a short series with both axes labelled, and save it
twice into io.BytesIO buffers: once as PNG and once as PDF. Do not pass
bbox_inches="tight": it crops the canvas, which is exactly what you want for a
manuscript and exactly what would break the size arithmetic being checked here.
Nothing is written to disk.
A PNG stores its dimensions in the IHDR chunk as two big-endian unsigned
integers at byte offsets 16 through 24, and its first eight bytes are the fixed
signature \x89PNG\r\n\x1a\n; a PDF begins with %PDF. Return four lines:
png=<w>x<h> valid=<bool>; expected=<w>x<h> matches=<bool>, where the
expected dimensions are the inch size times the DPI rounded to the nearest
integer; pdf=<bool>; and open_figures=<n> from plt.get_fignums() after
closing the figure, which must be zero: an unclosed figure in a loop is what
eventually kills a batch job.
Test cases · 3
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | 3.5 2.6 300 | png=1050x780 valid=True expected=1050x780 matches=True pdf=True open_figures=0 |
| 2 | stdin | 7.2 4.0 100 | png=720x400 valid=True expected=720x400 matches=True pdf=True open_figures=0 |
| 3 | stdin | 1.0 1.0 72 | png=72x72 valid=True expected=72x72 matches=True pdf=True open_figures=0 |
Hints · 3
01Hint
`struct.unpack(">II", raw[16:24])` reads the two IHDR dimensions.
02Hint
`fig.savefig` accepts any file-like object as long as you pass `format=`.
03Hint
`plt.close("all")` at the start makes the final figure count independent of anything drawn earlier.