On this page
Matplotlib styling is the gap between a chart that works and one you would put in a report. The fix is mostly configuration: set rcParams once, pick a style sheet, choose colours deliberately, annotate the insight and export with bbox_inches='tight'. A handful of those settings, mainly the spines and grid, does most of the work.
Old tutorials make this harder than it should be. Their plt.style.use('seaborn') now raises an OSError, and their rainbow colormaps invent boundaries that are not in the data. This part is for readers who can already produce basic plots and want control: the object-oriented API, defaults, colour, annotation, direct labels and export, each with code run on a recent release.
The code was run on Matplotlib 3.10. As of September 2026 the current release is 3.11, and every style name and function used here is in the 3.11 documentation.
Why use the object-oriented API?
Matplotlib has two interfaces. The pyplot state machine (plt.plot, plt.title) draws on whichever Axes is currently active. It is convenient for one chart, but unreliable for anything more.
The object-oriented API, by contrast, gives you explicit handles. Matplotlib's guide to its application interfaces notes that complicated plots often end up simpler this way.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 200)
fig, ax = plt.subplots(figsize=(8, 4.5))
ax.plot(x, np.sin(x), label='sin(x)')
ax.plot(x, np.cos(x), label='cos(x)')
ax.set_xlabel('x')
ax.set_ylabel('amplitude')
ax.set_title('Trigonometric functions')
ax.legend()
plt.show()
fig is the whole canvas, while ax is one plotting area. Every customisation below hangs off one of those two objects.
For grids of charts:
fig, axes = plt.subplots(2, 2, figsize=(10, 7), sharex=True)
axes[0, 0].plot(x, np.sin(x))
axes[0, 1].plot(x, np.cos(x))
axes[1, 0].hist(np.random.default_rng(0).normal(size=500), bins=30)
axes[1, 1].scatter(x[::5], np.sin(x[::5]))
fig.suptitle('Four panels', fontsize=14)
fig.tight_layout()
sharex=True links the axes, so setting limits on one applies across panels, and it also removes duplicated tick labels. As an alternative to tight_layout, you can pass layout='constrained' to plt.subplots.
How do you set matplotlib styling defaults once?
Rather than styling every chart, set rcParams at the top of the notebook:
plt.rcParams.update({
'figure.figsize': (8, 4.5),
'figure.dpi': 110,
'savefig.dpi': 300,
'savefig.bbox': 'tight',
'font.size': 11,
'axes.titlesize': 14,
'axes.titleweight': 'bold',
'axes.labelsize': 11,
'axes.spines.top': False,
'axes.spines.right': False,
'axes.grid': True,
'grid.alpha': 0.25,
'grid.linestyle': '--',
'legend.frameon': False,
'lines.linewidth': 2,
})
Those settings alone transform the default look. In particular, the spine removal and the faint dashed grid do most of the work.
Built-in style sheets are a quicker route:
print(plt.style.available)
plt.style.use('seaborn-v0_8-whitegrid')
Note the seaborn-v0_8- prefix. Older tutorials use plt.style.use('seaborn'), but that name no longer exists, and current Matplotlib raises OSError: 'seaborn' is not a valid package style. The style sheets reference shows every current name.
Styles can also be combined, with later styles overriding earlier ones, and applied temporarily with a context manager so they do not leak into other charts:
with plt.style.context('dark_background'):
fig, ax = plt.subplots()
ax.plot(x, np.sin(x))
How do you choose colours in Matplotlib?
Set your palette once through axes.prop_cycle, use a perceptually uniform colormap such as viridis for continuous data, and centre diverging data on zero. The default colour cycle is fine, but replacing it is a two-line change:
from cycler import cycler
plt.rcParams['axes.prop_cycle'] = cycler(
color=['#2563eb', '#f59e0b', '#10b981', '#ef4444', '#8b5cf6']
)
For continuous data, choose a perceptually uniform colormap such as viridis, magma or cividis. Avoid jet and rainbow: Matplotlib's colormap guide explains that their lightness rises and falls, so they create visual boundaries that do not exist in the data and become unreadable in greyscale.
For diverging data, centre the scale explicitly:
import matplotlib.colors as mcolors
matrix = np.random.default_rng(1).uniform(-0.3, 1, size=(5, 5))
fig, ax = plt.subplots()
norm = mcolors.TwoSlopeNorm(vmin=-1, vcenter=0, vmax=1)
im = ax.imshow(matrix, cmap='RdBu_r', norm=norm)
fig.colorbar(im, ax=ax, shrink=0.8)
Without TwoSlopeNorm, zero lands wherever the data happens to put it, and the colours mislead. In this example the values run from about -0.26 to 0.97, so a plain colour scale would put the neutral white at roughly 0.36 instead of at zero.
Why is annotation the most valuable customisation?
A chart that states its point beats a beautifully styled one that does not.
fig, ax = plt.subplots(figsize=(8, 4.5))
ax.plot(x, np.sin(x), color='#2563eb')
peak_x = x[np.argmax(np.sin(x))]
peak_y = np.sin(x).max()
ax.annotate(
'first peak',
xy=(peak_x, peak_y),
xytext=(peak_x + 1.5, peak_y + 0.25),
arrowprops={'arrowstyle': '->', 'color': '#475569'},
fontsize=10,
)
ax.axhline(0, color='#94a3b8', linewidth=1, linestyle='--')
ax.axvspan(6, 8, alpha=0.08, color='#2563eb')
ax.text(7, -0.9, 'region of interest', ha='center', fontsize=9, color='#475569')
Here np.argmax finds the first peak at x ≈ 1.56, so the arrow points at the real maximum even if the data changes. Meanwhile, axvspan shades a range and axhline adds a reference line.
Should you use a legend or label lines directly?
Label lines directly when there are only a few of them. A legend forces the reader to look back and forth; a label where each line ends does not:
fig, ax = plt.subplots(figsize=(8, 4.5))
series = {'sin': np.sin(x), 'cos': np.cos(x)}
colors = {'sin': '#2563eb', 'cos': '#f59e0b'}
for name, y in series.items():
ax.plot(x, y, color=colors[name])
ax.text(x[-1] + 0.15, y[-1], name,
color=colors[name], va='center', fontweight='bold')
ax.set_xlim(0, 11.5)
ax.spines[['top', 'right']].set_visible(False)
The extended xlim leaves room for the labels, and indexing ax.spines with a list hides both spines in one call.
How do you save a Matplotlib figure without clipped labels?
Pass bbox_inches='tight' to savefig, use dpi=300 for PNG, and prefer PDF or SVG for documents:
fig.savefig('chart.png', dpi=300, bbox_inches='tight')
fig.savefig('chart.pdf', bbox_inches='tight') # vector, scales cleanly
fig.savefig('chart.svg', bbox_inches='tight') # vector, editable
bbox_inches='tight' prevents clipped axis labels, which is the most common export complaint. For anything going into a document or slide deck, prefer PDF or SVG, since they stay sharp at any size. As the savefig reference notes, dpi only affects raster formats such as PNG.
Matplotlib styling quick reference
| Goal | Setting or call |
|---|---|
| Default figure size | 'figure.figsize': (8, 4.5) |
| Sharp PNG exports | 'savefig.dpi': 300 |
| No clipped labels | 'savefig.bbox': 'tight' or bbox_inches='tight' |
| Remove top/right spines | 'axes.spines.top': False, 'axes.spines.right': False |
| Faint grid | 'axes.grid': True, 'grid.alpha': 0.25 |
| Seaborn-like look | plt.style.use('seaborn-v0_8-whitegrid') |
| Temporary style | with plt.style.context('dark_background'): |
| Custom palette | cycler(color=[...]) on axes.prop_cycle |
| Diverging colours centred on zero | TwoSlopeNorm(vcenter=0) |
Common matplotlib styling mistakes
- Using
plt.style.use('seaborn'). The name was replaced by theseaborn-v0_8-*styles. - Styling chart by chart. Put shared settings in
rcParamsonce. - Choosing
jetfor continuous data. Useviridisorcividisinstead. - Forgetting
bbox_inches='tight'. Labels get cut off in the saved file. - Exporting slides as low-DPI PNGs. Use PDF or SVG, or at least
dpi=300. - Mixing
plt.andax.calls in one figure. Pick the object-oriented API and stick with it.
Which three styling changes matter most?
If you change only three things, use fig, ax, remove the top and right spines, and annotate the insight. Together, those cover more distance than every other tweak combined.
The Sunday Growth Brief
One email a week: the best new comparisons, a fresh roadmap and the tech news worth your attention.
No spam. Unsubscribe in one click.
Related reading
For interactive charts instead, see the Plotly tutorial, Part 1 and advanced Plotly charts in Part 2. For the data layer underneath, the NumPy tutorial series starts here. The fundamentals of figures and axes are covered in Matplotlib figures, axes and subplots.
Frequently asked questions
What is the difference between plt.plot and ax.plot?
plt.plot draws on whichever Axes pyplot currently considers active, which is convenient in a notebook but fragile in scripts with several figures. ax.plot targets one specific Axes object explicitly. Matplotlib's own documentation notes that complicated plots often end up simpler with the explicit Axes interface, so use it for anything beyond a throwaway chart.
How do I make matplotlib charts look less dated?
Three changes cover most of it. First, pick a modern style with plt.style.use, such as seaborn-v0_8-whitegrid. Second, remove the top and right spines through rcParams. Third, lighten the grid with a low alpha. After that, set a sensible default figure size and DPI once so every chart in the notebook inherits them.
How do I export a matplotlib chart for print?
Call savefig with dpi=300 for a raster PNG, or save as PDF or SVG for vector output that stays sharp at any size; the dpi setting does not affect vector formats. Always pass bbox_inches='tight' so axis labels and titles are not clipped at the edge of the saved file, which is the most common export complaint.
Should I use matplotlib or seaborn?
Seaborn is built on matplotlib and gives better defaults with less code for statistical charts. Use seaborn to draw and matplotlib to customise; they compose well because seaborn's axes-level functions, such as histplot and scatterplot, accept and return a matplotlib Axes. Everything in this guide, from rcParams to annotation, therefore still applies to a seaborn chart.
Written by
Vanshika Nigam
ETL & Data Engineer, Accenture
ETL developer at Accenture working with Informatica, Snowflake and DBT, specialising in data mapping, cleansing and pipeline performance.

