1Publication-Quality Figure Styling
▶
Creating a plot that communicates data clearly is one thing; creating a plot that meets the rigorous aesthetic and technical standards of a journal article, conference poster, or professional report is another challenge entirely. Publication-quality figures require deliberate control over every visual element — typography, color, line weight, tick marks, whitespace, and export resolution — so that the final output is both beautiful and precisely reproducible. Matplotlib provides a rich, layered system for achieving this level of control, ranging from high-level style sheets that transform an entire figure's look in a single line, down to low-level rcParams settings and Axes methods that tune individual elements with surgical precision. Mastering these tools transforms workmanlike exploratory plots into polished figures ready to drop directly into a manuscript or slide deck.
Using Style Sheets
Matplotlib ships with a collection of built-in style sheets that bundle dozens of coordinated visual settings — background color, grid visibility, font sizes, line widths, and color cycles — into a single named profile. Applying a style sheet globally is done with plt.style.use(), which accepts the name of any built-in style or the file path to a custom .mplstyle file. Once called, it affects every figure created afterward in the same script or notebook session.
import matplotlib.pyplot as plt
import numpy as np
plt.style.use('seaborn-v0_8-whitegrid')
x = np.linspace(0, 2 * np.pi, 200)
fig, ax = plt.subplots()
ax.plot(x, np.sin(x), label='sin(x)')
ax.plot(x, np.cos(x), label='cos(x)')
ax.legend()
plt.show()
Running that snippet replaces Matplotlib's default gray background and thin lines with a clean white grid, softer colors, and slightly larger fonts — all without touching a single parameter manually. Popular built-in styles include 'ggplot' (inspired by R's ggplot2), 'bmh' (Bayesian Methods for Hackers), 'dark_background' for dark-themed presentations, and the family of 'seaborn-*' variants. You can inspect the full list with print(plt.style.available).
Styles can also be layered by passing a list to plt.style.use(). Matplotlib applies the styles in order, with later entries overriding earlier ones wherever settings conflict. This lets you start with a broad aesthetic base and then layer a narrow customization on top:
# Start with a clean base, then override font settings with a custom sheet
plt.style.use(['seaborn-v0_8-paper', 'custom_fonts.mplstyle'])
A critical workflow concern is that plt.style.use() modifies the global rcParams state for the rest of the session. If you need to apply a style only to one particular figure — perhaps a dark theme for a presentation slide embedded in a notebook that otherwise uses a light theme — use the context manager form instead:
with plt.style.context('dark_background'):
fig, ax = plt.subplots()
ax.plot(x, np.sin(x), color='cyan')
plt.savefig('dark_figure.pdf')
# Back outside the block, global settings are completely restored
fig2, ax2 = plt.subplots()
ax2.plot(x, np.cos(x)) # Uses whatever style was active before the block
plt.show()
The context manager guarantees that the style is applied only within its block and that the previous state is fully restored upon exit, even if an exception is raised. This is the recommended approach whenever you need temporary style changes in interactive or notebook environments.
Customizing Colors and Colormaps
Color is one of the most powerful channels for encoding information in a figure, and Matplotlib supports a flexible vocabulary for specifying colors at every level of granularity. A color can be expressed in several equivalent ways:
- Named strings — Matplotlib recognizes all CSS4 color names such as
'steelblue','tomato','darkorange', and'mediumseagreen'. These are readable and self-documenting in code. - Hex codes — Standard six-digit HTML hex strings like
'#2c7bb6'give exact control over any of the 16.7 million RGB colors and are the easiest format to copy from a color picker or brand style guide. - RGB and RGBA tuples — Floating-point tuples in the range [0, 1] such as
(0.17, 0.48, 0.71)or(0.17, 0.48, 0.71, 0.8)(the last value being alpha for transparency) are useful in programmatic contexts where you compute colors mathematically. - Single-letter shortcuts —
'b'(blue),'r'(red),'g'(green),'k'(black),'w'(white) for quick interactive use.
fig, ax = plt.subplots()
ax.plot(x, np.sin(x), color='steelblue', linewidth=2)
ax.plot(x, np.cos(x), color='#e34a33', linewidth=2)
ax.plot(x, np.sin(x + 1), color=(0.2, 0.6, 0.2), linewidth=2)
For continuous data — such as a scatter plot where point color encodes a third numerical variable — colormaps provide a mapping from a range of values to a spectrum of colors. The function plt.cm.get_cmap() retrieves a colormap object by name:
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(42)
x_data = rng.uniform(0, 10, 100)
y_data = rng.uniform(0, 10, 100)
z_data = rng.uniform(0, 1, 100) # The third variable encoded as color
cmap = plt.cm.get_cmap('viridis') # Perceptually uniform, colorblind-friendly
fig, ax = plt.subplots()
sc = ax.scatter(x_data, y_data, c=z_data, cmap=cmap, s=60, edgecolors='none')
fig.colorbar(sc, ax=ax, label='Intensity')
plt.show()
Choosing the right colormap matters for both aesthetics and accessibility. 'viridis', 'plasma', and 'cividis' are perceptually uniform (equal steps in data correspond to equal steps in perceived color change) and remain distinguishable when printed in grayscale or viewed by people with color vision deficiencies. Diverging colormaps like 'RdBu' or 'coolwarm' work well when your data has a meaningful midpoint (such as zero in a correlation matrix). Avoid 'jet' in publication contexts — its non-uniform luminance profile creates artificial visual boundaries and is poorly reproduced in grayscale.
The property cycle controls the automatic sequence of colors (and other properties like linestyle or marker) that Matplotlib cycles through when you plot multiple series without specifying colors explicitly. You can customize it via rcParams or a style sheet:
from cycler import cycler
custom_cycle = cycler(color=['#003f5c', '#7a5195', '#ef5675', '#ffa600']) \
+ cycler(linewidth=[1.5, 1.5, 1.5, 1.5])
plt.rcParams['axes.prop_cycle'] = custom_cycle
fig, ax = plt.subplots()
for i in range(4):
ax.plot(x, np.sin(x + i * 0.5), label=f'Phase {i}')
ax.legend()
plt.show()
In this example the four lines automatically receive the four specified colors in order, and the cycle repeats if more than four series are added. This technique is invaluable for enforcing a consistent brand palette across all figures in a report.
Fonts, Tick Marks, and Spines
Typography and axis anatomy are subtle but powerful determinants of a figure's professionalism. Journals often specify exact font families, sizes, and axis tick formats in their figure preparation guidelines, so knowing how to control these elements precisely is essential.
Global font size is set through rcParams:
plt.rcParams['font.size'] = 10 # Base size for most text
plt.rcParams['axes.titlesize'] = 12 # Axes title
plt.rcParams['axes.labelsize'] = 10 # x and y axis labels
plt.rcParams['xtick.labelsize'] = 8 # Tick labels
plt.rcParams['ytick.labelsize'] = 8
plt.rcParams['legend.fontsize'] = 9
plt.rcParams['font.family'] = 'serif' # or 'sans-serif', 'monospace'
plt.rcParams['font.serif'] = ['Times New Roman', 'DejaVu Serif']
Setting these once at the top of a script ensures uniform typography throughout. For individual text elements, each text artist also accepts a fontsize keyword argument that overrides the global default.
Tick marks define the quantitative scale on an axis, and controlling their positions and labels is frequently necessary when the automatic choices are unsuitable — for example, to align ticks with meaningful data values, show fractions of π on a trigonometric plot, or reduce clutter on a densely labeled time axis:
fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(x, np.sin(x))
# Place ticks at multiples of pi/2
tick_positions = [0, np.pi/2, np.pi, 3*np.pi/2, 2*np.pi]
tick_labels = ['0', 'π/2', 'π', '3π/2', '2π']
ax.set_xticks(tick_positions)
ax.set_xticklabels(tick_labels, fontsize=10)
ax.set_yticks([-1, -0.5, 0, 0.5, 1])
plt.tight_layout()
plt.show()
The appearance of the tick marks themselves — their direction, length, width, and color — is controlled with ax.tick_params():
ax.tick_params(
axis='both', # Apply to both x and y axes
which='major', # Major ticks (use 'minor' for minor ticks)
direction='in', # Ticks point inward ('out' or 'inout' are alternatives)
length=5, # Tick length in points
width=0.8, # Tick line width
labelsize=9, # Font size of tick labels
top=True, # Also draw ticks on the top spine
right=True # Also draw ticks on the right spine
)
Pointing ticks inward and drawing them on all four sides of the axes frame is a convention in many physics and engineering journals, giving the figure a tighter, more authoritative appearance.
Spines are the four boundary lines of the axes box (top, bottom, left, right). Controlling them lets you create minimalist figures with only two visible spines (a common style in statistics and data journalism) or completely frameless axes:
fig, ax = plt.subplots()
ax.plot(x, np.sin(x))
# Hide the top and right spines
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
# Move the left spine to x=0 and bottom spine to y=0
ax.spines['left'].set_position('zero')
ax.spines['bottom'].set_position('zero')
# Adjust spine line width
ax.spines['left'].set_linewidth(0.8)
ax.spines['bottom'].set_linewidth(0.8)
plt.show()
You can also set the color of spines individually with ax.spines['bottom'].set_color('#333333'), which is useful when the figure background is not white.
Minor ticks, which subdivide the intervals between major ticks, add precision and are expected in certain scientific disciplines. They are enabled with the AutoMinorLocator:
from matplotlib.ticker import AutoMinorLocator, MultipleLocator
ax.xaxis.set_minor_locator(AutoMinorLocator(5)) # 4 minor ticks per major interval
ax.yaxis.set_minor_locator(MultipleLocator(0.1))
ax.tick_params(which='minor', length=3, width=0.6, direction='in')
Saving High-Resolution Figures
A figure that looks sharp on screen may appear blurry, pixelated, or incorrectly proportioned when embedded in a document, depending on how it was saved. Understanding the output pipeline is essential for avoiding these problems.
The dpi (dots per inch) parameter in plt.savefig() controls the pixel density of raster image formats such as PNG, JPEG, and TIFF. The default Matplotlib DPI is 100, which is fine for screen display but insufficient for print. Professional standards vary by context:
- 72–96 DPI — Screen display and web use.
- 150–200 DPI — Acceptable for presentations projected on screen.
- 300 DPI — Standard minimum for most academic journals and offset printing.
- 600 DPI — Required by some journals for line art (figures containing only lines and text with no photographic content).
fig.savefig('figure1.png', dpi=300)
fig.savefig('figure1_hires.tiff', dpi=600)
The bbox_inches='tight' argument is almost always necessary when saving figures, because Matplotlib's default bounding box encompasses only the axes area and frequently clips axis labels, legends, or colorbars that extend slightly outside it:
fig.savefig(
'figure1.png',
dpi=300,
bbox_inches='tight', # Expand bounding box to include all artists
pad_inches=0.05 # Add a small margin around the tight bounding box
)
Without bbox_inches='tight', the left y-axis label or bottom x-axis label is often partially or completely cut off in the saved file even though it appears fine in the interactive window. This is one of the most common and frustrating gotchas when submitting figures for the first time.
Vector formats — PDF, SVG, and EPS — are fundamentally different from raster formats in that they store the figure as a set of mathematical drawing instructions rather than a grid of pixels. This means they can be scaled to any size — from a postage stamp to a billboard — without any loss of sharpness. They are the preferred format for:
- Line art, diagrams, and figures containing only discrete geometric elements and text.
- Figures that will be embedded in LaTeX documents (PDF is the native vector format for
pdflatexandlualatex). - Figures that may be edited after the fact in vector graphics software such as Adobe Illustrator or Inkscape for final layout adjustments.
- Any situation where file size matters, because a complex vector figure is often dramatically smaller than a high-DPI raster equivalent.
# PDF — ideal for LaTeX and most print workflows
fig.savefig('figure1.pdf', bbox_inches='tight')
# SVG — ideal for web embedding and editing in Inkscape
fig.savefig('figure1.svg', bbox_inches='tight')
# EPS — required by some older journal submission systems
fig.savefig('figure1.eps', bbox_inches='tight')
One nuance with vector output is that the dpi parameter still affects raster elements embedded within a vector figure — for example, a imshow() image panel inside a PDF. Setting dpi=300 even when saving to PDF ensures that any such embedded rasters remain sharp.
A complete, production-ready figure saving workflow typically looks like this:
import matplotlib.pyplot as plt
import numpy as np
plt.rcParams.update({
'font.family': 'serif',
'font.size': 10,
'axes.labelsize': 10,
'xtick.labelsize': 8,
'ytick.labelsize': 8,
'legend.fontsize': 9,
'figure.dpi': 150, # Screen preview resolution
})
x = np.linspace(0, 2 * np.pi, 300)
fig, ax = plt.subplots(figsize=(3.5, 2.8)) # Width sized for a single column (inches)
ax.plot(x, np.sin(x), color='#2c7bb6', linewidth=1.2, label=r'$\sin(x)$')
ax.plot(x, np.cos(x), color='#d7191c', linewidth=1.2, label=r'$\cos(x)$')
ax.set_xlabel(r'$x$ (radians)')
ax.set_ylabel('Amplitude')
ax.legend(frameon=False, loc='upper right')
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.tick_params(direction='in', length=4, width=0.7)
plt.tight_layout()
fig.savefig('figure1.pdf', dpi=300, bbox_inches='tight', pad_inches=0.02)
fig.savefig('figure1.png', dpi=300, bbox_inches='tight', pad_inches=0.02)
plt.show()
This example encapsulates the full workflow: global parameter setup, figure sizing matched to a journal's single-column width (typically 3.0–3.5 inches), careful color selection, minimal spine styling, inward ticks, and dual-format export for both vector (PDF) and raster (PNG) submission requirements. Developing a personal template like this — saved as a .mplstyle file or a Python module — dramatically accelerates the process of producing consistent, high-quality figures across a project.