Matplotlib Practical Tips: Publication-Quality Figures in Python (plt.rcParams, savefig, colormap, subplots, tight_layout)

Publication-quality matplotlib figures in Python with plt.rcParams, savefig, subplots, tight_layout, mpl.font_manager, and seaborn.set_context. Covers LaTeX rendering (text.usetex / mathtext), perceptually uniform colormaps (viridis / cividis / magma), vector output (PDF / SVG with pdf.fonttype=42), dpi=300 and figsize control, margin polishing, seaborn-paper styling, and rasterization tuning. A practical reference for papers, conferences, and presentations.

Introduction

Matplotlib’s default settings produce figures that are often unsuitable for publications or presentations. Font sizes are too small, lines are too thin, and the overall appearance lacks polish. With a few configuration changes, you can produce figures that are ready for direct inclusion in papers.

This article covers practical tips for creating publication-quality figures with Matplotlib.

Global Configuration with rcParams

plt.rcParams lets you set default styles for your entire script, eliminating the need to configure each figure individually. Here is a template you can copy into your projects.

import matplotlib.pyplot as plt

plt.rcParams.update({
    'font.size': 12,
    'axes.labelsize': 14,
    'axes.titlesize': 14,
    'xtick.labelsize': 11,
    'ytick.labelsize': 11,
    'legend.fontsize': 11,
    'figure.figsize': (6, 4),
    'figure.dpi': 150,
    'lines.linewidth': 1.5,
    'axes.linewidth': 0.8,
    'axes.grid': True,
    'grid.alpha': 0.3,
})

Parameter Breakdown

  • font.size: Base font size. Other size parameters scale relative to this value.
  • axes.labelsize, axes.titlesize: Axis label and title size. Setting these slightly larger than the base size improves readability.
  • figure.figsize: Default figure dimensions in inches. Adjust to match your journal’s column width.
  • figure.dpi: Resolution. 150 or higher ensures crisp display in notebooks.
  • axes.grid, grid.alpha: A subtle grid helps readers extract data values from the plot.

Default vs custom settings

Combining with Style Sheets

Matplotlib includes preset style sheets. When combining them with rcParams, apply the style sheet first, then override individual settings.

plt.style.use('seaborn-v0_8-whitegrid')
plt.rcParams.update({
    'font.size': 12,
    'axes.labelsize': 14,
})

Font Configuration

macOS

On macOS, system fonts like Hiragino Sans are available. You can register them manually for non-ASCII text support.

import matplotlib
from matplotlib import font_manager

font_path = '/System/Library/Fonts/Helvetica.ttc'
font_manager.fontManager.addfont(font_path)
matplotlib.rc('font', family='Helvetica')

For Japanese text on macOS, the japanize-matplotlib package is the simplest solution.

pip install japanize-matplotlib
import japanize_matplotlib

Linux

On Linux, install your preferred fonts and configure the path.

# List available fonts
fc-list

# Install Noto Sans (Ubuntu/Debian)
sudo apt install fonts-noto-cjk

Then register the font in Matplotlib.

import matplotlib
from matplotlib import font_manager

font_path = '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc'
font_manager.fontManager.addfont(font_path)
matplotlib.rc('font', family='Noto Sans CJK JP')

If fonts are not recognized after installation, clear the font cache.

matplotlib.font_manager._load_fontmanager(try_read_cache=False)

Colormap Selection

Why Avoid jet and rainbow

The jet and rainbow colormaps are problematic for two reasons. First, they are not perceptually uniform – brightness varies inconsistently, which can misrepresent data magnitude. Second, they are difficult to interpret for readers with color vision deficiencies.

For continuous data, use one of these perceptually uniform colormaps.

  • viridis: The default colormap. Perceptually uniform and accessible.
  • cividis: Similar to viridis but specifically optimized for color vision deficiency accessibility.
  • plasma: A warm-toned alternative. Useful for distinguishing from viridis when presenting multiple heatmaps.
import numpy as np
import matplotlib.pyplot as plt

x = np.linspace(0, 10, 100)
y = np.linspace(0, 10, 100)
X, Y = np.meshgrid(x, y)
Z = np.sin(X) * np.cos(Y)

fig, axes = plt.subplots(1, 3, figsize=(15, 4))
cmaps = ['viridis', 'cividis', 'plasma']

for ax, cmap in zip(axes, cmaps):
    im = ax.pcolormesh(X, Y, Z, cmap=cmap)
    ax.set_title(cmap)
    fig.colorbar(im, ax=ax)

plt.tight_layout()
plt.show()

Colormap comparison

Qualitative Palettes for Categorical Data

When plotting multiple series in line charts or bar charts, use qualitative palettes where each color is visually distinct.

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)
x = np.linspace(0, 10, 100)

# tab10: Matplotlib's default color cycle (up to 10 distinct colors)
colors = plt.cm.tab10.colors

for i in range(5):
    plt.plot(x, np.sin(x + i), color=colors[i], label=f'Series {i+1}')

plt.legend()
plt.show()

Set2 and Dark2 are also good choices for publications due to their muted, professional tones.

Don’t judge “distinct enough” by eye

The advice above is common across most Matplotlib tutorials, but when you actually check tab10, Set2, and Dark2 from a color vision deficiency (CVD) standpoint, all three contain at least one adjacent pair that is hard to tell apart. Running a CVD simulation based on the physiologically-based model from Machado, Oliveira & Fernandes (2009) and computing the color difference (ΔE — higher means more distinguishable; the commonly cited target is ΔE ≥ 12) gives:

PaletteClosest color pairΔE under protanopiaVerdict
tab10 (default)orange #ff7f0e ↔ green #2ca02c4.6effectively the same color
Set2pink #e78ac3 ↔ blue-violet #8da0cb2.5effectively the same color
Dark2olive #e6ab02 ↔ green #66a61e9.5borderline (needs a secondary visual cue)
petroff10 (Matplotlib 3.10+)brown #b9ac70 ↔ orange #e7630029.3clearly distinguishable

In other words, “Set2 and Dark2 look tasteful for publications” is reasonable aesthetic advice, but once you overlay five or more series on a line chart, readers with protanopia/deuteranopia (red-green CVD) may find specific series effectively indistinguishable. Here is what that looks like when simulated:

tab10 vs. petroff10 under normal vision and simulated protanopia

The top row is normal vision; the bottom row is simulated protanopia. In tab10 (left), the orange, green, and (partially) red lines collapse into nearly the same olive tone in the bottom row, while petroff10 (right) keeps all five series distinguishable. This simulated image was generated with the code below (the PROTANOPIA_MATRIX values were verified to match those returned by the colour-science library’s colour.blindness.matrix_cvd_Machado2009("Protanomaly", 1.0)):

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)

# Machado, Oliveira & Fernandes (2009) protanopia simulation matrix
# (applied in linear RGB space)
PROTANOPIA_MATRIX = np.array([
    [0.152286, 1.052583, -0.204868],
    [0.114503, 0.786281, 0.099216],
    [-0.003882, -0.048116, 1.051998],
])


def simulate_protanopia(rgb):
    """Apply protanopia CVD simulation to an (H, W, 3) array of sRGB values in [0, 1]."""
    linear = np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
    sim_linear = np.clip(linear @ PROTANOPIA_MATRIX.T, 0, 1)
    srgb = np.where(
        sim_linear <= 0.0031308,
        sim_linear * 12.92,
        1.055 * sim_linear ** (1 / 2.4) - 0.055,
    )
    return np.clip(srgb, 0, 1)


def plot_with_cycle(ax, colors, title):
    x = np.linspace(0, 4 * np.pi, 200)
    for i, c in enumerate(colors):
        ax.plot(x, np.sin(x + i * 0.6) + i * 0.18, color=c, linewidth=2.5, label=f"Series {i+1}")
    ax.set_title(title)


tab10 = list(plt.cm.tab10.colors[:5])
with plt.style.context("petroff10"):
    petroff10 = list(plt.rcParams["axes.prop_cycle"].by_key()["color"])[:5]

fig, axes = plt.subplots(1, 2, figsize=(9, 3.4))
plot_with_cycle(axes[0], tab10, "tab10 (Matplotlib default cycle)")
plot_with_cycle(axes[1], petroff10, "petroff10 (Matplotlib >= 3.10)")
fig.tight_layout()
fig.canvas.draw()

rgba = np.asarray(fig.canvas.buffer_rgba()).astype(float) / 255
simulated = simulate_protanopia(rgba[..., :3])
plt.imsave("simulated_protanopia.png", simulated)

When CVD accessibility genuinely matters, verify with a tool rather than by eye — the same principle this article applies to colormap selection.

2024–2025 Update: CVD-Aware Palettes Are Now Built In

Matplotlib 3.10 (released December 2024) added the petroff10 style sheet (plus 6- and 8-color variants petroff6/petroff8), designed by combining CVD modeling with an aesthetics model trained on a crowdsourced color-preference survey. It’s used exactly like any other style sheet:

plt.style.use('petroff10')

Matplotlib 3.11 further adds okabe_ito, the color-vision-deficiency-friendly qualitative palette from Okabe & Ito (2008), as a built-in qualitative colormap. If you want to swap the color scheme without touching existing plotting code, switching the whole style sheet via plt.style.use() is the simplest approach. Separately, if you want to interactively inspect data point values in a notebook, mplcursors (pip install mplcursors) adds a clickable tooltip on data points.

Subplot Alignment and Shared Axes

Basic Subplots with Shared Axes

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)
fig, axes = plt.subplots(2, 2, figsize=(10, 8), sharex=True, sharey=True)

for ax in axes.flat:
    ax.plot(np.random.randn(50).cumsum())

fig.align_ylabels(axes[:, 0])
plt.tight_layout()
plt.show()

Setting sharex=True and sharey=True unifies axis ranges across all subplots. fig.align_ylabels() ensures y-axis labels are vertically aligned. Here is what the difference actually looks like:

Comparison with and without sharex/sharey/align_ylabels

On the left (before), without sharey=True, each panel scales its own axis to its own data range independently, so the fact that Signal 2’s swing is more than 3x larger than the others is invisible from the plot. On the right (after), with sharey=True, all four panels share the same y-axis range (-20 to 20), so amplitudes become directly comparable across panels. Matplotlib also automatically hides redundant tick labels on interior subplots when sharex/sharey are set — that’s why the top row’s x-axis tick labels are omitted.

tight_layout vs. constrained_layout: How They Actually Compute Spacing

Overlapping titles and axis labels between subplots are usually fixed just by calling plt.tight_layout() (or passing a layout argument), but understanding what happens under the hood helps when it doesn’t work. First, let’s confirm the overlap actually gets fixed.

import numpy as np
import matplotlib.pyplot as plt


def build_figure(layout):
    np.random.seed(42)
    fig, axes = plt.subplots(2, 2, figsize=(5, 4.2), layout=layout)
    for i, ax in enumerate(axes.flat):
        ax.plot(np.random.randn(50).cumsum())
        ax.set_title(f"Very Long Subplot Title #{i}")
        ax.set_ylabel("Cumulative sum of\nrandom walk")
        ax.set_xlabel("Time step")
    return fig


for layout in [None, "tight", "constrained"]:
    fig = build_figure(layout)
    fig.savefig(f"layout_{layout or 'none'}.png", dpi=150)

How each layout engine resolves title/label overlap

With layout=None (left), the long titles and neighboring panels’ y-axis labels overlap and become unreadable. Both layout='tight' (middle) and layout='constrained' (right) resolve this overlap. So what actually differs between the two algorithms?

  • tight_layout: Renders the figure once, retrieves the actual bounding boxes of each Axes’ tick labels, axis labels, and title from the renderer, and retroactively recomputes subplotpars (left/right/top/bottom/wspace/hspace) so that they don’t overlap. Because it is based purely on each Axes’ own decorations, it does not account for elements that aren’t tied to a single Axes — a legend or colorbar spanning multiple Axes, for instance.
  • constrained_layout: Uses a constraint solver that treats every decorative element in the figure — subplots, axis labels, titles, legends, colorbars, and the suptitle — as an object that needs space, and solves for all of them simultaneously so nothing overlaps. Because it re-solves on every draw, the layout keeps up automatically when elements are added or changed.

The difference becomes obvious once you add a colorbar shared across multiple Axes.

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)
x = np.linspace(-3, 3, 60)
X, Y = np.meshgrid(x, x)
Z = np.sin(X) * np.cos(Y)

for layout in ["tight", "constrained"]:
    fig, axes = plt.subplots(2, 2, figsize=(6, 5), layout=layout)
    for i, ax in enumerate(axes.flat):
        im = ax.pcolormesh(X, Y, Z + 0.1 * i, cmap="viridis")
        ax.set_title(f"Panel {i}")
    fig.colorbar(im, ax=axes, shrink=0.8, label="Value")
    fig.suptitle(f"layout='{layout}'")
    fig.savefig(f"cbar_{layout}.png", dpi=150)

How tight_layout and constrained_layout handle a shared colorbar differently

With layout='tight' (left), Matplotlib actually emits the warning “This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.” at runtime, and the shared colorbar is drawn overlapping Panel 1 and Panel 3. With layout='constrained' (right), no warning is emitted and space for the colorbar is reserved correctly. tight_layout is fine if you only need to fix simple title/label overlap, but for figures containing colorbars, legends, or a suptitle, layout='constrained' (set via plt.subplots(..., layout='constrained')) is the safer choice. For grids of fixed-aspect-ratio Axes (e.g., imshow), there is also layout='compressed', which pulls the Axes closer together.

Fine-Tuning Layout with gridspec_kw

To control spacing between subplots, use gridspec_kw.

fig, axes = plt.subplots(
    2, 2,
    figsize=(10, 8),
    gridspec_kw={'hspace': 0.05, 'wspace': 0.05},
    sharex=True,
    sharey=True
)

Subplots with Different Sizes

GridSpec allows you to define subplots of varying sizes within a single figure.

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec

np.random.seed(42)
t = np.linspace(0, 10, 200)
main_signal = np.sin(t) + 0.15 * np.random.randn(200)

fig = plt.figure(figsize=(10, 6), layout="constrained")
gs = GridSpec(2, 3, figure=fig)

ax_main = fig.add_subplot(gs[:, :2])  # Large plot spanning left 2/3
ax_main.plot(t, main_signal)
ax_main.set_title("Main signal (spans 2 rows x 2 cols)")
ax_main.set_xlabel("Time [s]")
ax_main.set_ylabel("Amplitude")

ax_top = fig.add_subplot(gs[0, 2])  # Top right
ax_top.hist(main_signal, bins=15, color="tab:blue")
ax_top.set_title("Distribution")

ax_bottom = fig.add_subplot(gs[1, 2])  # Bottom right
ax_bottom.plot(t, np.gradient(main_signal, t), color="tab:orange")
ax_bottom.set_title("Derivative")

fig.savefig("gridspec_layout_demo.png", dpi=150)

Asymmetric subplot layout with GridSpec

The large panel on the left spans two rows and two columns, while two smaller panels stack vertically on the right. This is a common layout for pairing a main signal with its distribution and derivative in a single figure.

Vector Output (PDF/SVG)

Saving in Vector Format

For paper submissions, vector formats (PDF, SVG) are preferred because they scale without quality loss.

fig, ax = plt.subplots()
ax.plot([1, 2, 3], [1, 4, 9])

# PDF output
fig.savefig('figure.pdf', bbox_inches='tight')

# SVG output
fig.savefig('figure.svg', bbox_inches='tight')

bbox_inches='tight' automatically trims excess whitespace around the figure.

When to Use Raster Format

Raster format (PNG) is more appropriate in certain cases:

  • Scatter plots with a very large number of points (tens of thousands or more)
  • Heatmaps and image plots
  • When file size needs to be minimized

For raster output, specify the dpi parameter.

fig.savefig('figure.png', dpi=300, bbox_inches='tight')

Mixing Vector and Raster in One Figure

You can selectively rasterize heavy elements within a vector figure. Elements with rasterized=True are rendered as bitmaps, while the rest remains vector.

ax.scatter(x, y, rasterized=True)  # Only the scatter plot is rasterized
fig.savefig('figure.pdf', dpi=300, bbox_inches='tight')

Complete Example: Combining All Tips

Here is a full example that brings together all the tips covered in this article.

import numpy as np
import matplotlib.pyplot as plt

# Global configuration
plt.rcParams.update({
    'font.size': 12,
    'axes.labelsize': 14,
    'axes.titlesize': 14,
    'xtick.labelsize': 11,
    'ytick.labelsize': 11,
    'legend.fontsize': 11,
    'figure.figsize': (6, 4),
    'figure.dpi': 150,
    'lines.linewidth': 1.5,
    'axes.linewidth': 0.8,
    'axes.grid': True,
    'grid.alpha': 0.3,
})

# Generate sample data
np.random.seed(42)
x = np.linspace(0, 2 * np.pi, 100)
signals = {
    'Signal A': np.sin(x) + np.random.normal(0, 0.1, 100),
    'Signal B': np.cos(x) + np.random.normal(0, 0.1, 100),
    'Signal C': np.sin(2 * x) + np.random.normal(0, 0.1, 100),
}

# Qualitative palette for categorical data
colors = plt.cm.Set2.colors

# Create subplots
fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))

# Left: Line chart
ax = axes[0]
for i, (label, data) in enumerate(signals.items()):
    ax.plot(x, data, color=colors[i], label=label)
ax.set_xlabel('Time [s]')
ax.set_ylabel('Amplitude')
ax.set_title('Time Series Comparison')
ax.legend()

# Right: Heatmap
ax = axes[1]
X, Y = np.meshgrid(x, x)
Z = np.sin(X) * np.cos(Y)
im = ax.pcolormesh(X, Y, Z, cmap='cividis', rasterized=True)
ax.set_xlabel('X')
ax.set_ylabel('Y')
ax.set_title('2D Heatmap')
fig.colorbar(im, ax=ax)

plt.tight_layout()

# Save in vector format
fig.savefig('publication_figure.pdf', bbox_inches='tight')
fig.savefig('publication_figure.svg', bbox_inches='tight')
plt.show()

Complete example: publication-quality figure

LaTeX Math Rendering and Serialization Recipes

When a figure will be embedded directly into a paper, you often want axis labels and equations rendered in the same LaTeX font as the body text. Enabling text.usetex=True tells matplotlib.pyplot to shell out to a real LaTeX installation for typesetting, so equations and symbols match your manuscript exactly.

import matplotlib.pyplot as plt
import numpy as np

plt.rcParams.update({
    'text.usetex': True,              # Use the system LaTeX engine
    'font.family': 'serif',
    'font.serif': ['Computer Modern Roman'],
    'text.latex.preamble': r'\usepackage{amsmath}\usepackage{siunitx}',
    'axes.labelsize': 12,
    'figure.figsize': (5.5, 3.5),
    'pdf.fonttype': 42,               # Embed TrueType fonts
    'ps.fonttype': 42,
    'savefig.bbox': 'tight',
    'savefig.pad_inches': 0.02,
})

t = np.linspace(0, 2 * np.pi, 200)
fig, ax = plt.subplots()
ax.plot(t, np.sin(t) * np.exp(-t / 5),
        label=r'$x(t) = \sin(t)\,e^{-t/5}$')
ax.set_xlabel(r'Time $t$ [\si{\second}]')
ax.set_ylabel(r'$x(t)$')
ax.set_title(r'Damped Oscillation: $\omega_0 = 1\,\si{\radian\per\second}$')
ax.legend(frameon=False)
fig.savefig('damped.pdf')   # PDF with LaTeX math, vector
fig.savefig('damped.svg')   # SVG

Setting pdf.fonttype=42 ensures fonts are embedded as TrueType, so the publisher’s preprocessor will not silently substitute glyphs. If you cannot install a full LaTeX distribution, leave text.usetex=False and Matplotlib’s built-in mathtext engine will still render $...$ expressions. Setting mathtext.fontset='cm' produces Computer Modern–like glyphs that visually match a LaTeX document.

savefig Recipe Table by Medium

The optimal savefig parameters depend on where the figure will end up. The table below summarizes recipes I reach for most often.

Use caseRecommended formatKey parameters
Paper body (submission PDF)figure.pdfdpi=300, bbox_inches='tight', pad_inches=0.02, pdf.fonttype=42
Editable vector (for typesetters)figure.svgbbox_inches='tight', transparent=False
Presentation slide (high-res PNG)figure.pngdpi=300, bbox_inches='tight', facecolor='white'
Blog / web (lightweight PNG)figure_web.pngdpi=150, bbox_inches='tight', facecolor='white'
Thumbnailthumb.pngdpi=72, bbox_inches='tight', figsize=(3,2)
Print (CMYK pre-press)figure.pdf + GhostscriptPost-process with gs -sDEVICE=pdfwrite -dProcessColorModel=/DeviceCMYK

Keep pad_inches small (0.02 in.) so the figure does not leave awkward whitespace inside a two-column layout. Setting facecolor='white' matters for slide decks with dark themes — otherwise the figure appears with a transparent background that displays incorrectly on dark backgrounds.

Combining with the seaborn “paper” Context

seaborn’s set_context('paper') automatically tunes font sizes and line widths for paper-sized figures. Pair it with manual rcParams overrides to keep the defaults but tweak a few specifics.

import seaborn as sns
sns.set_context('paper', font_scale=1.1)
sns.set_style('whitegrid', {'grid.alpha': 0.3})
plt.rcParams.update({'figure.dpi': 200, 'savefig.dpi': 300})

This pattern works well when you want seaborn’s tasteful defaults but need a different colorbar font size or a higher save DPI — the explicit rcParams.update wins after the seaborn call.

Common Pitfalls

Even with all the settings above in place, two pitfalls trip up nearly everyone at some point. Let’s reproduce both concretely.

savefig() After show() Can Silently Save a Blank Image

Calling plt.savefig() after plt.show() can produce a completely blank saved image. The cause lies in Matplotlib’s state management — pyplot’s global notion of the “current figure.” With an interactive GUI backend (MacOSX, TkAgg, Qt5Agg, etc.), a blocking plt.show() call destroys the Figure object from pyplot’s registry as soon as the window is closed. If you then call argument-less plt.savefig(), pyplot looks for a “current figure” to save, finds none, and plt.gcf() silently creates a brand-new empty Figure — which is what gets saved.

import matplotlib

matplotlib.use("MacOSX")  # a real interactive backend is needed to reproduce this
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)

# WRONG: savefig() after show()
plt.plot(np.arange(20), np.random.randn(20).cumsum())
plt.title("My Figure")
plt.show()  # blocks; once the window is closed, this Figure is destroyed
plt.savefig("wrong_blank.png")  # saves a NEW, empty figure

plt.close("all")

# RIGHT: keep a reference to the Figure/Axes and save explicitly, before show()
fig, ax = plt.subplots()
ax.plot(np.arange(20), np.random.randn(20).cumsum())
ax.set_title("My Figure")
fig.savefig("right_correct.png")  # save first
plt.show()

savefig() called after show() vs. before show()

The left result is from calling savefig() after show() (a 4,410-byte blank image); the right result is from calling fig.savefig() before show() (32,033 bytes, correctly rendered). This bug does not reproduce with the non-interactive Agg backend (as used in CI environments or headless servers) because show() is effectively a no-op there. Much of the “it looked fine locally but the server produced a broken image (or vice versa)” confusion traces back to exactly this backend difference. Two reliable fixes:

  • Always call plt.savefig() (or anything relying on pyplot’s global state) before plt.show()
  • Don’t rely on pyplot’s implicit state at all — call fig.savefig() explicitly on the Figure/Axes objects returned by plt.subplots() (the object-oriented interface)

DPI and figsize Interaction: Looks Fine on Screen, Tiny or Huge When Saved

Matplotlib font sizes and line widths are specified in points (1 pt = 1/72 inch). The saved pixel dimensions are determined by figsize (in inches) times dpi, so if you keep figsize and font.size fixed and only change dpi, the physical size of the text (in inches) stays the same, but its size in pixels scales proportionally with dpi.

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)

for dpi in [72, 150, 300]:
    plt.rcParams.update({'font.size': 11})
    fig, ax = plt.subplots(figsize=(4, 3), layout='constrained')  # inches, identical for all three
    x = np.linspace(0, 2 * np.pi, 100)
    ax.plot(x, np.sin(x) + 0.05 * np.random.randn(100))
    ax.set_title('Measured Signal')
    ax.set_xlabel('Time [s]')
    ax.set_ylabel('Amplitude')
    fig.savefig(f'signal_dpi{dpi}.png', dpi=dpi)

Measured results:

dpiOutput pixel sizeFile size11pt title height (theoretical = 11pt x dpi/72)
72288 x 21612 KB11.0 px
150600 x 45030 KB22.9 px
3001200 x 90067 KB45.8 px

Same figsize and font.size, only dpi varied

All three are displayed at the same width (500px). The dpi=72 image has fewer native pixels, so text and lines look soft/blurry once scaled up to that display width. Conversely, if you set figure.dpi high in a notebook to make a plot “look crisp” during development, but leave savefig()’s dpi argument (or the savefig.dpi rcParam) at a lower value, the saved file will look coarser than what you saw on screen. To avoid this mismatch:

  • Remember that the display resolution (figure.dpi) and the save resolution (savefig()’s dpi argument, or savefig.dpi) are separate settings
  • For blog/web use, set dpi=150-ish explicitly; for print/publication, set dpi=300 or higher (see the “savefig Recipe Table by Medium” earlier in this article)
  • Distinguish between raising dpi while keeping figsize fixed (more absolute pixels, larger file) and changing figsize itself (changes the relative layout proportions) — these are different operations

Summary

Key takeaways for creating publication-quality figures:

  • Define global settings with rcParams to maintain consistent styling across all figures
  • Configure fonts explicitly to ensure correct rendering on all platforms
  • Choose perceptually uniform colormaps like viridis for accessibility. Verify categorical color choices with a CVD simulation rather than by eye — petroff10 (Matplotlib 3.10+) is a pre-verified option
  • Use sharex/sharey and align_ylabels to keep subplots aligned. For complex layouts with colorbars or legends, use constrained_layout
  • Call savefig() before show(), explicitly as fig.savefig(). Manage display DPI and save DPI as separate concerns
  • Save final output in PDF or SVG vector format for lossless scaling

For an introduction to Matplotlib basics and creating 3D animations (GIF), see also Creating 3D Animations with Matplotlib .