Saving Figures

savefig, dpi and the arguments that decide whether the file looks like the screen.

Overview

Size and resolution

A figure has a size in inches (figsize) and a resolution in dots per inch (dpi). The saved image is their product in pixels.

figsize=(6, 3) at dpi=100 gives 600×300. At dpi=200 it gives 1200×600.

That much is arithmetic. The part that catches people is that these two are not interchangeable:

figsize=(4, 2), dpi=200 → 800×400 figsize=(8, 4), dpi=100 → 800×400

Same pixels, different-looking charts. Text is sized in points, a physical unit, so 10-point text occupies a tenth of an inch either way. On the 4-inch figure that is a large fraction of the width; on the 8-inch one it is half as much.

The rule that follows: choose figsize for the layout — how big the text should look relative to the plot — and dpi for the resolution. Then scaling up for a high-resolution export changes only sharpness, not proportions.

A common mistake is making a figure bigger to get more detail and finding the text has shrunk relative to everything else.

Worth knowing

Pixel size is figsize × dpi. figsize is inches, and it decides how large the text looks relative to the plot.
The same pixel dimensions from a small figure at high dpi and a large one at low dpi are not equivalent — text scales differently.
bbox_inches="tight" stops labels being cropped, and changes the output size as a side effect.
PNG for the web; PDF or SVG for print or anything that will be resized.
Vector files grow with the number of elements, so a huge scatter belongs in a rasterised layer.
transparent=True drops the background, but the text stays dark — a figure for a dark page needs its colours changed too.

Saving Figures

savefig, dpi, and the arguments that decide whether the file looks like the screen.

savefig writes what the figure declares

Not what the screen shows, which is why sizes surprise people.

example_01.pymatplotlib
Output

dpi changes apparent text size

Because the text is sized in points, which are physical units.

example_02.pymatplotlib
Output

bbox_inches='tight'

The fix for labels cut off at the edge.

example_03.pymatplotlib
Output

Vector or raster

PNG for the web, PDF or SVG for print and for anything to be resized.

example_04.pymatplotlib
Output

Transparency and the background

The default is a white background, which is wrong on a dark page.

example_05.pymatplotlib
Output

Saving in a loop

The pattern that works, and the one that exhausts memory.

example_06.pymatplotlib
Output

The pattern for generating many charts:

for group, data in groups:
    fig, ax = plt.subplots()
    ...
    fig.savefig(f"{group}.png", dpi=150, bbox_inches="tight")
    plt.close(fig)

plt.close(fig) is the line people omit. Without it every figure stays open, matplotlib warns after twenty, and a loop over a few thousand groups exhausts memory.

For output that goes into a document, saving as PDF and letting the document scale it usually beats guessing a dpi.

And savefig accepts a file-like object as well as a path, which is how you write straight into a buffer for a web response or a test — as every editor on this page does, since there is no filesystem to write to.

bbox_inches

By default savefig writes exactly the declared figure area. Labels, titles and legends drawn outside the axes may fall outside it and be cropped.

bbox_inches="tight" recomputes the bounding box to include everything drawn. It is the fix for the very common "my y-axis label is missing from the saved file".

Two consequences: the output dimensions are no longer exactly figsize × dpi, and pad_inches controls the margin it leaves. If you need an exact pixel size, use layout management instead and leave the bbox alone.

Formats

PNG is raster: a grid of pixels. Right for the web, for slides, and anywhere the display size is known. Small for simple charts.

PDF is vector: shapes and text. Scales to any size without blurring, embeds fonts, and is what a journal or a print process wants.

SVG is vector and text-based, so it can be edited afterwards in Inkscape or styled with CSS in a browser.

JPEG should be avoided for charts: it is lossy in a way that puts artefacts around sharp edges and text, which is exactly what a chart is made of.

The trade-off with vector formats is that file size grows with the number of elements, not the image dimensions. A line chart is tiny; a scatter plot of 100,000 points is an enormous PDF that may take a viewer minutes to render.

rasterized=True on a specific artist stores just that layer as pixels while keeping text and axes as vectors, which gives a small file with sharp labels.

Background

The default background is white for both the figure and the axes.

transparent=True makes both transparent, so the chart sits on whatever is behind it. facecolor="#222" sets a specific colour.

The catch: transparency changes the background and not the foreground. Text, spines and tick labels stay their original dark colour, so a transparent figure dropped onto a dark slide has invisible labels. Making a chart for a dark background means changing the text and line colours too, which is what a dark style sheet does.

dpi in three places

dpi appears in the figure, in savefig, and in rcParams, and they interact.

plt.subplots(dpi=100) sets the figure's own dpi, which affects on-screen size.

fig.savefig(path, dpi=200) overrides it for that file.

rcParams["savefig.dpi"] sets the default for saving, and defaults to "figure", meaning "use the figure's".

The practical consequence is that a figure looking right on screen can save at a different resolution than expected, and passing dpi explicitly at save time removes the question.

Common values: 100 for a quick look, 150–200 for slides and web, 300 for print, 600 for a journal that asks for it.

Metadata and reproducibility

savefig accepts a metadata dict, which for PNG and PDF is written into the file.

Recording the script, the data version and the timestamp there means a chart found later can be traced back:

fig.savefig(path, metadata={"Software": "analysis.py", "Creation Date": stamp})

A more visible version is a small caption in figure coordinates giving the source and date, which survives the chart being copied into a document where the file metadata does not follow.

Vector text and fonts

By default, PDF and SVG output stores text as text, which keeps it selectable and searchable, and requires the reader to have the font.

rcParams["pdf.fonttype"] = 42 embeds the font as TrueType, making the file self-contained at the cost of size. Journals frequently require this, and it is the fix when a submitted figure renders in the wrong typeface.

rcParams["svg.fonttype"] = "none" does the opposite for SVG, leaving text as text so it can be styled with CSS in a browser — useful for the web, wrong for a document.

Saving several formats

A common pattern is one call per format from the same figure:

for ext in ("png", "pdf"):
    fig.savefig(f"{name}.{ext}", dpi=200, bbox_inches="tight")

The figure can be saved any number of times; nothing is consumed. A PNG for the draft and a PDF for the final document, from one drawing pass.

Buffers rather than files

savefig accepts any file-like object:

buf = io.BytesIO()
fig.savefig(buf, format="png")

That is how a chart becomes a web response, an email attachment, or a test assertion — and how these pages work, since the browser has no filesystem to write into.

format is required when there is no filename to infer it from, which is the usual first error with this.

A saving function

For a project producing many figures, a small wrapper enforces consistency:

def save(fig, name, formats=("png", "pdf")):
    for ext in formats:
        fig.savefig(f"figures/{name}.{ext}", dpi=200,
                    bbox_inches="tight", facecolor="white")
    plt.close(fig)

One place to change the dpi, the formats, the background and the padding, and the close is not forgotten.

Adding the source script and a timestamp to the metadata, or as a small caption, makes a figure traceable months later — which is the difference between a chart that can be updated and one that has to be remade.

Common saving problems

Labels cut off — missing bbox_inches="tight" or layout management.

Blurry in a document — a raster format at too low a dpi; use PDF, or 200–300 dpi.

Enormous PDF — a dense scatter stored as vector; rasterise that layer.

Wrong font on another machine — the font was not embedded; pdf.fonttype = 42.

Invisible text on a dark backgroundtransparent=True changed the background and not the foreground.

Different from the screen — the screen was a different size; the file is authoritative.

Each has a one-line fix, and all of them are easier to prevent in a saving function than to diagnose per figure.

Figures in a pipeline

When charts are generated automatically — a weekly report, a dashboard build, a model evaluation — a few properties matter more than they do for a one-off.

Determinism. The same input should produce the same file. That means seeding any randomness, sorting anything whose order is not guaranteed, and pinning the style rather than inheriting whatever is configured.

Self-description. The chart should carry its own date range and source, because it will be found later without its context.

Failure behaviour. A chart generated from empty data should produce something explicit rather than an empty axes; a check and a clear message beats a blank rectangle in a report.

Size predictability. Labels grow with the data, so bbox_inches="tight" and a figure size derived from the number of categories prevent a layout that worked in testing from cropping in production.

None of these is about matplotlib specifically. They are the difference between a chart that runs unattended and one that has to be looked at every week.

In summary

Pixel size is figsize × dpi, and figsize decides how large the text looks relative to the plot — so the two are not interchangeable ways of getting the same resolution.

bbox_inches="tight" is the fix for cropped labels, and changes the output dimensions as a side effect.

PNG for the web, PDF or SVG for print and anything resized, and never JPEG for a chart.

Vector file size grows with the number of elements, so a dense scatter wants rasterized=True on that artist.

transparent=True changes the background and not the text, so a chart for a dark page needs its foreground recoloured too.

And in any loop that saves, plt.close(fig) — the figures do not clean themselves up, and the warning arrives long after the memory has started growing.

Choosing dpi and size together

The two are usually chosen backwards: a default size, then a dpi high enough to look sharp.

The order that works is destination first.

Print at 300 dpi, one column widefigsize=(3.4, 2.4), dpi=300, fonts around 8 points.

A slidefigsize=(10, 5.6), dpi=150, fonts 14 or larger.

A web page at 700 px widefigsize=(7, 4), dpi=200 for retina displays, then let CSS scale it down.

A quick look → whatever the default is.

Setting the size to the destination means the fonts can be chosen once and are right, and no scaling happens afterwards to disturb the proportions.

The test is whether the figure needs resizing when it arrives where it is going. If it does, it was drawn at the wrong size.

Checking the output

The saved file is what other people see, and it differs from the screen in ways worth checking once per project rather than per figure.

Open it at 100%. Labels that are comfortable in a scaled preview may be too small.

Check the edges. Anything drawn outside the axes is the first thing to be cropped.

Check the background. A transparent figure over an unexpected background, or a white border around a dark chart.

Check the file size. A surprisingly large PDF means a dense artist that should be rasterised.

Open it in the destination — the document, the slide, the page — because that is where the size and the background are decided.

Doing this once when the saving function is written catches problems that would otherwise recur in every figure the project produces.

One more thing

fig.savefig accepts pad_inches=0 alongside bbox_inches="tight", which removes the border entirely.

That is what you want for a figure being embedded in a layout that provides its own spacing, and what you do not want for one being viewed on its own, where the border is what stops the labels touching the edge.

The short version

The saved file is the artefact; the screen is a preview.

Size and dpi are one decision made for the destination, bbox_inches="tight" prevents the most common cropping, vector formats need rasterised layers for dense data, and every loop that saves needs to close.

Reading the code back

A save is one call with four arguments that matter: the format, the dpi, the bounding box and the background. Wrapping them in a project function means they are decided once, applied everywhere, and the close is not forgotten. The check that the output is right is to open the file at full size in the place it will be used.

Check yourself

0 of 4

Answer without scrolling back up.

  1. `figsize=(4,2) dpi=200` and `figsize=(8,4) dpi=100` both give 800x400. How do they differ?

  2. Your saved figure is missing its y-axis label. What fixes it?

  3. Why can a scatter of 100,000 points make a huge PDF?

  4. What does `transparent=True` NOT change?

Cheat sheet

Saving Figures

Same pixels, different-looking charts. Text is sized in points, a physical unit, so 10-point text occupies a tenth of an inch either way. On the 4-inch figure that is a large fraction of the width; on the 8-inch one it is half as much.

MATPLOTLIB · vizlearn.in/matplotlib/saving_figures.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.