# Advanced Matplotlib Styling and Customisation in Python (Part 5)

> Matplotlib styling for report-ready charts: rcParams, style sheets, colormaps, annotation and clean export, and why plt.style.use('seaborn') now fails.

- **Author:** Vanshika Nigam — ETL & Data Engineer, Accenture (https://www.1stepgrow.com/authors/vanshika-nigam/)
- **Published:** Jul 30, 2026 · **Updated:** Sep 17, 2026
- **Topic:** Python & Programming · **Format:** Guide · **Read time:** 9 min
- **Canonical URL:** https://www.1stepgrow.com/articles/matplotlib-styling-customization/

## Key takeaways

- Use the object-oriented API (fig, ax) rather than the pyplot state machine, because it is the reliable way to control complex figures.
- rcParams set once at the top of a notebook beat repeating the same styling on every chart.
- Removing chart junk, such as the top and right spines and heavy grids, improves almost every default matplotlib figure.
- Direct labelling usually beats a legend, and one annotation that names the insight beats any colour tweak.

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](https://matplotlib.org/stable/users/explain/customizing.html).

## 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](https://matplotlib.org/stable/users/explain/figure/api_interfaces.html) notes that complicated plots often end up simpler this way.

```python
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:

```python
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:

```python
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:

```python
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](https://matplotlib.org/stable/gallery/style_sheets/style_sheets_reference.html) 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:

```python
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:

```python
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](https://matplotlib.org/stable/users/explain/colors/colormaps.html) 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:

```python
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.

```python
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:

```python
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:

```python
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](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.savefig.html) 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 the `seaborn-v0_8-*` styles.
- **Styling chart by chart.** Put shared settings in `rcParams` once.
- **Choosing `jet` for continuous data.** Use `viridis` or `cividis` instead.
- **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.` and `ax.` 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.



## Related reading

For interactive charts instead, see the [Plotly tutorial, Part 1](https://www.1stepgrow.com/articles/plotly-tutorial) and [advanced Plotly charts in Part 2](https://www.1stepgrow.com/articles/advanced-plotly-charts). For the data layer underneath, the [NumPy tutorial series](https://www.1stepgrow.com/articles/numpy-tutorial) starts here. The fundamentals of figures and axes are covered in [Matplotlib figures, axes and subplots](https://www.1stepgrow.com/articles/matplotlib-figures-axes-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.

---
_Source: 1stepGrow (https://www.1stepgrow.com/articles/matplotlib-styling-customization/). Cite with the title, "1stepGrow" and a link._
