savefig, dpi and the arguments that decide whether the file looks like the screen.
Overview
Size and resolution
A chart that looks right on screen can look wrong when saved. Text too small, margins too wide, resolution too low for print. savefig controls all of these, and understanding its parameters prevents the most common export problems.
The relationship between figsize and dpi is the first thing to understand. figsize sets the physical dimensions of the figure in inches. dpi sets the number of pixels per inch. The product gives the pixel dimensions of the saved file. The catch is that text size is measured in points, a physical unit. So two figures with the same pixel count but different figsize values produce different-looking charts. The text is the same physical size but takes up a different fraction of the figure.
This page covers size and resolution, bounding-box trimming, format selection, transparency, the savefig defaults that trip people up, and the workflow for producing figures that look right in slides, papers and web pages.
The most common export mistake is not trimming whitespace. By default, savefig includes the full figure area, including wide margins around the axes. bbox_inches="tight" trims to the content. Adding pad_inches=0.1 leaves a small border. These two parameters should be on nearly every savefig call.
Format matters. PNG is a raster format: fixed pixels, no scaling. SVG and PDF are vector formats: lines and text stay sharp at any zoom. For web use, PNG is standard. For papers and slides, PDF or SVG is better because the figure stays crisp when the reader zooms in.
A figure has a size in inches (figsize) and a detail 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 math. The part that catches people is that these two aren't the same:
Same pixels, other-looking charts. Text is sized in points, a physical unit. 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's half as much.
The rule that follows: choose figsize for the layout. How big the text should look scaled to the plot. And dpi for the detail. Then scaling up for a high-detail export changes only sharpness, not proportions.
A common mistake is making a figure bigger to get more detail and finding the text has shrunk scaled to all 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 args 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
import matplotlib.pyplot as plt
import io, os
fig, ax = plt.subplots(figsize=(6, 3), dpi=100)
ax.plot([1, 2, 3], [2, 1, 3])
ax.set_title("6 x 3 inches at 100 dpi")
buf = io.BytesIO()
fig.savefig(buf, format="png")
size = len(buf.getvalue())
print("figsize :", fig.get_size_inches(), "inches")
print("dpi :", fig.dpi)
print("pixels : %d x %d" % (6 * 100, 3 * 100))
print("file bytes :", size)
print()
print("figsize is in INCHES and dpi is dots per inch, so the pixel")
print("dimensions are their product. To get a 1200px wide image, use")
print("figsize=(6, 3) with dpi=200, or figsize=(12, 6) with dpi=100 -")
print("and those two are not the same: the second has smaller text.")
Output
dpi changes apparent text size
Because the text is sized in points, which are physical units.
example_02.pymatplotlib
import matplotlib.pyplot as plt
import io
for figsize, dpi in [((4, 2), 200), ((8, 4), 100)]:
fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
ax.plot([1, 2, 3], [2, 1, 3])
ax.set_title("figsize=%s dpi=%d" % (figsize, dpi))
buf = io.BytesIO()
fig.savefig(buf, format="png")
print("figsize %-8s dpi %3d -> %d x %d px, %5d bytes"
% (str(figsize), dpi, figsize[0]*dpi, figsize[1]*dpi, len(buf.getvalue())))
print()
print("Both are 800x400. The first has text twice as large relative to")
print("the plot, because 10-point text is 10 points on a 4-inch figure")
print("and 10 points on an 8-inch one.")
print()
print("So: choose figsize for the layout, dpi for the resolution.")
Output
bbox_inches='tight'
The fix for labels cut off at the edge.
example_03.pymatplotlib
import matplotlib.pyplot as plt
import io
fig, ax = plt.subplots(figsize=(4, 2.5))
ax.plot([1, 2, 3], [2, 1, 3])
ax.set_ylabel("a rather long y axis label")
ax.set_title("title")
plain = io.BytesIO(); fig.savefig(plain, format="png")
tight = io.BytesIO(); fig.savefig(tight, format="png", bbox_inches="tight")
print("default : %d bytes" % len(plain.getvalue()))
print("bbox_inches tight: %d bytes" % len(tight.getvalue()))
print()
print("'tight' recomputes the bounding box to include everything drawn,")
print("so labels outside the axes are not cropped. It also changes the")
print("output size, which matters if you need an exact pixel count.")
print()
print("pad_inches=0.1 controls the border it leaves.")
Output
Vector or raster
PNG for the web, PDF or SVG for print and for anything to be resized.
example_04.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
import io
rng = np.random.default_rng(0)
fig, ax = plt.subplots(figsize=(5, 3))
ax.plot(rng.random(200).cumsum())
ax.set_title("200 points")
for fmt in ["png", "pdf", "svg"]:
buf = io.BytesIO()
fig.savefig(buf, format=fmt)
print("%-4s %7d bytes" % (fmt, len(buf.getvalue())))
print()
print("png : pixels. Fixed resolution, small for simple charts.")
print("pdf : vector. Scales to any size, embeds fonts, right for print.")
print("svg : vector, and editable afterwards in Inkscape or a browser.")
print()
print("Vector formats grow with the NUMBER OF ELEMENTS, so a scatter of")
print("100,000 points makes an enormous PDF. Rasterise those layers.")
Output
Transparency and the background
The default is a white background, which is wrong on a dark page.
example_05.pymatplotlib
import matplotlib.pyplot as plt
import io
fig, ax = plt.subplots(figsize=(5, 2.5))
ax.plot([1, 2, 3], [2, 1, 3], color="crimson", linewidth=2)
ax.set_title("saved three ways")
opts = [
dict(),
dict(transparent=True),
dict(facecolor="#222222"),
]
for o in opts:
buf = io.BytesIO()
fig.savefig(buf, format="png", **o)
print("%-28s %6d bytes" % (str(o) if o else "default (white)", len(buf.getvalue())))
print()
print("transparent=True drops both the figure and axes background, so")
print("the chart sits on whatever is behind it.")
print()
print("Note the TEXT stays dark either way - a transparent figure on a")
print("dark background still needs its text and spines recoloured.")
Output
Saving in a loop
The pattern that works, and the one that exhausts memory.
example_06.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
import io
rng = np.random.default_rng(1)
sizes = []
for i in range(5):
fig, ax = plt.subplots(figsize=(3, 1.6))
ax.plot(rng.random(30).cumsum())
ax.set_title("chart %d" % i, fontsize=9)
buf = io.BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight")
sizes.append(len(buf.getvalue()))
plt.close(fig) # <- the important line
print("saved %d charts, sizes: %s" % (len(sizes), sizes))
print("figures still open:", len(plt.get_fignums()))
print()
print("Without plt.close(fig) they all stay open, matplotlib warns")
print("after 20, and a long loop runs out of memory.")
fig, ax = plt.subplots(figsize=(4, 2))
ax.bar(range(5), sizes)
ax.set_title("file sizes")
Output
The pattern for making 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 each figure stays open, matplotlib warns after twenty. A loop over a few thousand groups exhausts memory.
For output that goes into a doc, saving as PDF and letting the doc scale it often beats guessing a dpi.
And savefig accepts a file-like object and a path. This Is how you write straight into a buffer for a web response or a test. As each editor on this page does. There's no filesystem to write to.
bbox_inches
By default savefig writes just 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 all drawn. It's the fix for the very common "my y-axis label is missing from the saved file".
Two results: the output dims are no longer just figsize × dpi. 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. Anywhere the display size is known. Small for simple charts.
PDF is vector: shapes and text. Scales to any size without blurring, embeds fonts. Is what a journal or a print process wants.
SVG is vector and text-based. So It can be edited later in Inkscape or styled with CSS in a browser.
JPEG should be avoided for charts: it's lossy in a way that puts artefacts around sharp edges and text. This Is just 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 dims. A line chart is tiny. A scatter plot of 100,000 points is an huge PDF that may take a viewer minutes to render.
rasterized=True on a exact artist stores just that layer as pixels while keeping text and axes as vectors. This 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 what is behind it. facecolor="#222" sets a exact colour.
The catch: transparency changes the background and not the foreground. Text, spines and tick labels stay their first dark colour. 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. This 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. This Affects on-screen size.
fig.savefig(path, dpi=200) replaces it for that file.
rcParams["savefig.dpi"] sets the default for saving, and defaults to "figure", meaning "use the figure's".
The hands-on result is that a figure looking right on screen can save at a other detail than expected. Passing dpi by name at save time drops 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. This 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:
A more visible version is a small caption in figure coords giving the source and date. This Lives the chart being copied into a doc where the file metadata doesn't follow.
Vector text and fonts
By default, PDF and SVG output stores text as text. This Keeps it selectable and searchable. Needs the reader to have the font.
rcParams["pdf.fonttype"] = 42 embeds the font as TrueType, making the file self-held at the cost of size. Journals often need this. It's 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. handy for the web, wrong for a doc.
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 doc, 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 turns a web response, an email attachment, or a test assertion. And how these pages work. The browser has no filesystem to write into.
format is needed when there's no filename to infer it from. This Is the usual first error with this.
A saving function
For a project making many figures, a small wrapper enforces agreement:
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 isn't 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 gap 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 doc — a raster format at too low a dpi. Use PDF, or 200–300 dpi.
Huge PDF — a dense scatter stored as vector. Rasterise that layer.
Wrong font on another machine. The font wasn't embedded. pdf.fonttype = 42.
Invisible text on a dark background. transparent=True changed the background and not the foreground.
other from the screen — the screen was a other 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 made on its own. A weekly report, a dashboard build, a model check. A few properties matter more than they do for a one-off.
Determinism. The same input should give the same file. That means seeding any noise, sorting anything whose order isn't sure. Pinning the style rather than taking from what is configured.
Self-description. The chart should carry its own date range and source. It will be found later without its context.
Failure action. A chart made from empty data should give a thing clear 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. bbox_inches="tight" and a figure size derived from the number of groups prevent a layout that worked in testing from cropping in production.
None of these is about matplotlib namely. They're the gap between a chart that runs unattended and one that has to be looked at each week.
Saving Figures, in summary
Pixel size is figsize × dpi. figsize decides how large the text looks scaled to the plot. So the two aren't the same ways of getting the same detail.
bbox_inches="tight" is the fix for cropped labels. Changes the output dims as a side effect.
PNG for the web, PDF or SVG for print and anything resized. Never JPEG for a chart.
Vector file size grows with the number of elements. A dense scatter wants rasterized=True on that artist.
transparent=True changes the background and not the text. A chart for a dark page needs its foreground recoloured too.
And in any loop that saves, plt.close(fig). The figures don't clean themselves up. The warning arrives long after the memory has started growing.
Choosing dpi and size together
The two are often 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 wide → figsize=(3.4, 2.4), dpi=300, fonts around 8 points.
A slide → figsize=(10, 5.6), dpi=150, fonts 14 or larger.
A web page at 700 px wide → figsize=(7, 4), dpi=200 for retina displays, then let CSS scale it down.
A quick look → what the default is.
Setting the size to the destination means the fonts can be chosen once and are right. No scaling happens later to disturb the proportions.
The test is whether the figure needs resizing when it arrives where it's going. If it does, it was drawn at the wrong size.
Checking the output
The saved file is what other people see. 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 surprise background, or a white border around a dark chart.
Check the file size. A oddly large PDF means a dense artist that should be rasterised.
Open it in the destination. The doc, 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 else recur in each figure the project makes.
One more thing
fig.savefig accepts pad_inches=0 alongside bbox_inches="tight". This Drops the border fully.
That is what you want for a figure being embedded in a layout that gives its own spacing. What you don't want for one being viewed on its own. Here The border is what stops the labels touching the edge.
Saving Figures: the short version
The saved file is the artefact. The screen is a preview.
Size and dpi are one choice made for the destination, bbox_inches="tight" blocks the most common cropping, vector formats need rasterised layers for dense data. Each loop that saves needs to close.
Questions people ask about Saving Figures
What resolution should I save at? Use 150 to 200 dpi for the web and 300 for print. Vector formats such as SVG and PDF need no dpi.
Why is my saved figure cropped? Labels extend outside the default box. Pass bbox_inches='tight' to savefig.
Why is my saved background transparent or white?savefig uses its own facecolor. Set facecolor= or transparent=True explicitly.
Which format should I choose? PNG for the web and slides, SVG or PDF for print and papers. Vector files stay sharp at any size.
Why does savefig give a blank image? You called plt.show() first. In some backends that closes the figure. Call savefig before show, or save through the fig object.
How do I save many figures without running out of memory? Call plt.close(fig) after each save. Open figures stay in memory until closed.
Reading the code back
A save is one call with four args that matter. The format, the dpi, the bounding box and the background. Wrapping them in a project function means they're decided once, applied all over. The close isn't 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.
`figsize=(4,2) dpi=200` and `figsize=(8,4) dpi=100` both give 800x400. How do they differ?
Choose figsize for layout and dpi for resolution - then a high-resolution export changes sharpness, not proportions.
Your saved figure is missing its y-axis label. What fixes it?
savefig writes the declared figure area, and labels drawn outside it get cropped. It also changes the output dimensions.
Why can a scatter of 100,000 points make a huge PDF?
rasterized=True on that artist stores it as pixels while keeping text and axes as vectors.
What does `transparent=True` NOT change?
A transparent figure dropped on a dark slide has invisible labels - a dark background needs the foreground recoloured too.
Cheat sheet
Saving Figures
Save matplotlib figures with savefig. Set the DPI, file format and bounding box so the saved image matches what you see on screen.
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.