← All tutorials
PythonData Analysis

Matplotlib Styling: How to Turn a Default Chart Into a Polished One

A hands-on chart makeover: take one default matplotlib bar chart and rebuild it step by step, cutting chart junk, choosing a deliberate color palette, trying plt.style.use(), adding direct annotations, and saving it at a real publication DPI.

You’ve already made the chart — ax.bar(...) ran, something showed up, and it technically answers your question. But it looks like every other default matplotlib chart anyone has ever posted to Stack Overflow: a flat blue-ish color that means nothing, a box drawn around data that didn’t ask for one, and a title that doesn’t exist. If you’re still deciding which library to reach for in the first place, our guide to matplotlib, seaborn, pandas, and plotly covers that decision — start there. This post assumes you’ve already picked one and asks a narrower question: how do you take the chart you already have and make it look like you meant it?

This is where solid analysis quietly undersells itself — the numbers are right, but the chart looks like a debugging print statement, so nobody trusts it as much as they should. The fix isn’t a plugin or a “beautiful chart” library. It’s a short, repeatable set of habits: cut what isn’t earning its place, choose color on purpose, and label the one thing you want the reader to notice. We’ll build all of it on one real chart, end to end.

The Mental Model: Every Pixel Either Carries Information or It Doesn’t

Before touching any code, it helps to have one test you can run on any element of a chart: does this pixel tell the reader something they need, or is it just there because a default put it there?

A bar’s height carries information. A color assigned deliberately to a category carries information. A title that states the actual finding carries information. But a box drawn around the data on all four sides usually doesn’t — the reader already knows where the plot area ends. A grid line for every tick usually doesn’t, unless someone needs to read off precise values. A legend repeating a label already sitting next to the bar it describes doesn’t either. Call all of that chart junk: pixels spent without paying the reader back in meaning.

The process, in three steps:

  1. Identify what’s actually encoding data — usually the position and height/length of the marks, sometimes color.
  2. Remove everything else by default: spines, gridlines, ticks, legends you don’t strictly need.
  3. Add back only what orients the reader — one clear title, minimal axis labels, and a direct annotation on the point that matters, instead of forcing them to cross-reference a legend.

That’s the whole method. Everything below is one dataset run through those three steps.

A Dataset You Can Reproduce

We’ll use penguins, a small, real dataset of body measurements for three penguin species, collected at three islands near Palmer Station, Antarctica, and bundled directly with seaborn — no download, no API key.

import matplotlib
matplotlib.use("Agg")  # headless backend — see the gotchas section below
import matplotlib.pyplot as plt
import seaborn as sns

penguins = sns.load_dataset("penguins")
penguins[["species", "island", "body_mass_g"]].head()
  species     island  body_mass_g
0  Adelie  Torgersen       3750.0
1  Adelie  Torgersen       3800.0
2  Adelie  Torgersen       3250.0
3  Adelie  Torgersen          NaN
4  Adelie  Torgersen       3450.0

Data: the Palmer Penguins dataset (Gorman, Williams & Fraser, 2014, via the Palmer Station Antarctica LTER, CC0), bundled with seaborn (seaborn.load_dataset). 344 rows, three species — Adelie, Chinstrap, and Gentoo — with a handful of genuine missing measurements (body_mass_g is NaN for 2 rows), which groupby quietly drops on its own. Say you’re putting together a one-chart summary for a conservation newsletter: how much heavier is a Gentoo penguin than the other two species, on average?

means = penguins.groupby("species", observed=True)["body_mass_g"].mean().round(1)
means
species
Adelie       3700.7
Chinstrap    3733.1
Gentoo       5076.0
Name: body_mass_g, dtype: float64

(The outputs in this post come from matplotlib 3.11, seaborn 0.13.2, and pandas 3.0.3 — everything shown also works on the current matplotlib 3.x / seaborn 0.13.x line.) That means Series — three numbers — is the entire dataset this post’s chart needs. Everything from here is about how honestly it gets drawn.

The Default Chart: What’s Actually Wrong Here

Here’s a very ordinary first pass — group, bar-plot, and reflexively call ax.grid(True) because someone once told you grids “help readability”:

fig, ax = plt.subplots(figsize=(6, 4))
ax.bar(means.index, means.values)
ax.set_xlabel("species")
ax.set_ylabel("body_mass_g")
ax.grid(True)

len(ax.patches), {s: ax.spines[s].get_visible() for s in ax.spines}, ax.get_ygridlines()[0].get_visible()
(3, {'left': True, 'right': True, 'bottom': True, 'top': True}, True)

This post’s verification script runs headlessly, so rather than describe a screenshot, the chart’s real state confirms the problem directly: all four spines are visible, gridlines are on, and there’s no title — ax.get_title() returns ''. Every bar is the exact same flat blue, (0.122, 0.467, 0.706), matplotlib’s default tab:blue, chosen because it’s first in the color cycle, not because it means “Adelie” or “Chinstrap” or anything else. The xlabel/ylabel are raw column names instead of something a reader would say out loud. None of this is wrong — the bars are the right height. It’s just costing attention without giving anything back.

Cutting the Chart Junk: Spines and Gridlines

The fix for most of that is a few lines, not a new library:

fig, ax = plt.subplots(figsize=(6, 4))
ax.bar(means.index, means.values)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_visible(False)
ax.grid(False)
ax.tick_params(axis="both", length=0)

{s: ax.spines[s].get_visible() for s in ax.spines}
{'left': False, 'right': False, 'bottom': True, 'top': False}

Only the bottom spine survives — it’s the one line that orients the reader, the baseline the bars grow from. The top, left, and right spines were a box the data never needed; bars already show where they start and end. ax.grid(False) turns the reflexive gridlines back off — we’ll only bring one back if a reader needs to read off a precise in-between value, which this chart won’t once it’s directly annotated. tick_params(length=0) removes the tick marks poking out from the axis, one more line of pure clutter.

Choosing a Deliberate Color Palette

Matplotlib’s default color cycle exists to keep multiple unrelated series visually distinct with minimal thought — it was never meant to be a considered choice for a specific chart. species here is nominal categorical data: Adelie, Chinstrap, and Gentoo have no inherent order, so the right kind of palette is a small qualitative one — a handful of hues, kept at roughly similar lightness and saturation, so no single bar visually “wins” just because of an arbitrary cycle position:

palette = {"Adelie": "#0067c0", "Chinstrap": "#c76b28", "Gentoo": "#2f7d4f"}
colors = [palette[s] for s in means.index]

fig, ax = plt.subplots(figsize=(6, 4))
ax.bar(means.index, means.values, color=colors)
for s in ax.spines:
    ax.spines[s].set_visible(s == "bottom")
ax.set_yticks([])

colors
['#0067c0', '#c76b28', '#2f7d4f']

Blue, amber, and green, each with a comparable amount of “weight” on the page. Worth being explicit about when this call is different: if we were instead encoding an ordered or continuous quantity — say, coloring points by body mass itself — a qualitative palette would be the wrong tool, because three arbitrary hues don’t visually imply “more” or “less.” That’s a job for a sequential colormap, one hue getting lighter or darker with the value, so color itself reads as magnitude. Categorical data gets a qualitative palette; ordered or continuous data gets a sequential one — picking the wrong one is a common way a chart looks deliberate but reads confusingly.

The Fast Path: plt.style.use() and Built-In Styles

Hand-tuning spines and colors is worth it for a chart that matters. For a quick exploratory plot, matplotlib ships a set of ready-made global styles you can turn on in one line:

built_in = sorted(plt.style.available)
len(built_in), [s for s in built_in if "ggplot" in s or "fivethirtyeight" in s]
(28, ['fivethirtyeight', 'ggplot'])

Applying one changes matplotlib’s global defaults for every chart drawn afterward, without touching a single ax call:

plt.style.use("seaborn-v0_8-whitegrid")
fig, ax = plt.subplots(figsize=(6, 4))
ax.bar(means.index, means.values, color=colors)
ax.set_title("Average body mass by species (plt.style.use only)")

plt.rcParams["axes.grid"], plt.rcParams["axes.edgecolor"]
(True, '.8')

Compare that to the plain defaults from a moment ago — axes.grid was False, axes.edgecolor was 'black'; after plt.style.use("seaborn-v0_8-whitegrid"), gridlines are on and the spine color has softened to a light grey. One call bought a more polished baseline look, no manual spine work required. The full matplotlib style sheets reference previews every built-in option, the fastest way to pick one without trial-and-error. The catch: this change is global and sticks around for every plot drawn after it, in the same session or script — the first gotcha below.

Titles, Labels, and Annotations That Add Information

The chart’s honest job is answering “how much heavier is a Gentoo penguin?” — so say that directly, and put the actual number on the bar it belongs to instead of making the reader estimate it against an axis:

fig, ax = plt.subplots(figsize=(7, 4.5))
bars = ax.bar(means.index, means.values, color=colors, width=0.6)
for s in ax.spines:
    ax.spines[s].set_visible(s == "bottom")
ax.set_yticks([])
ax.set_title("Gentoo penguins are the outlier, not just the tallest bar",
             fontsize=12, fontweight="bold", loc="left")

for bar, species in zip(bars, means.index):
    height = bar.get_height()
    ax.annotate(f"{height:,.0f} g", (bar.get_x() + bar.get_width() / 2, height),
                xytext=(0, 6), textcoords="offset points", ha="center",
                fontsize=10, fontweight="bold")

diff = means["Gentoo"] - means[["Adelie", "Chinstrap"]].mean()
round(diff, 1)
1359.1

Each bar now carries its own exact value — 3,701 g, 3,733 g, 5,076 g — printed where the reader is already looking, so there’s no legend to cross-reference and no axis to squint at. The title states the finding instead of describing the chart type, and the callout does the arithmetic the reader would otherwise do themselves: Gentoo penguins average 1,359 g heavier than the mean of the other two species, more than a third heavier again. Nothing here needs a caption to be understood.

Bar chart titled Gentoo penguins are the outlier, not just the tallest bar, showing average body mass by penguin species from the Palmer Penguins dataset: Adelie 3,701 grams, Chinstrap 3,733 grams, and Gentoo 5,076 grams, each bar labeled directly with its value in a deliberate blue, amber, and green palette, no gridlines, no legend, and only a bottom axis line, with a callout noting Gentoo averages 1,359 grams heavier than the other two species combined.

Saving a Publication-Quality Figure

A chart that looks sharp in a Jupyter cell can still come out blurry the moment it leaves your notebook for a slide, a PDF, or a printed page — because “looks sharp on screen” and “has enough pixels for print” are two different things. savefig() needs to be told explicitly:

fig.savefig("body-mass-by-species.png", dpi=300, bbox_inches="tight", format="png")

dpi=300 is the standard target for print-quality raster output (dots per inch); bbox_inches="tight" trims the excess whitespace matplotlib otherwise leaves around the figure, instead of you cropping it by hand. For this exact figure (figsize=(7, 4.5), tight-cropped), that produces a 2,070 × 1,318 pixel PNG at roughly 81 KB — compare that to dpi=100, which comes out 690 × 441 pixels at about 23 KB, noticeably softer once scaled up on a slide or a printed page. If the chart is going into a web page rather than print, format="svg" is usually the better call: a vector format stays crisp at any zoom with no DPI decision to make, which is exactly how this post’s own thumbnail and diagram images are built.

Four Gotchas Worth Knowing

plt.style.use() changes matplotlib’s global defaults and doesn’t undo itself. Call it once, anywhere in a notebook or script, and every chart drawn afterward inherits the new look — including ones that have nothing to do with why you called it:

plt.style.use("default")
before = plt.rcParams["axes.facecolor"], plt.rcParams["axes.grid"]
plt.style.use("dark_background")
after = plt.rcParams["axes.facecolor"], plt.rcParams["axes.grid"]
before, after
(('white', False), ('black', False))

That axes.facecolor change from 'white' to 'black' is still sitting in plt.rcParams for the next chart you draw, whether you meant it to be there or not — a common way notebooks end up with one inexplicably dark plot three cells later. If you only want a style for one chart, scope it instead:

with plt.style.context("dark_background"):
    inside = plt.rcParams["axes.facecolor"]
outside = plt.rcParams["axes.facecolor"]
inside, outside
('black', 'white')

plt.style.context() applies the style only inside the with block and reverts automatically on exit — outside is back to 'white', no manual cleanup needed.

Color that doesn’t encode anything isn’t neutral — if it’s assigned by position instead of identity, it can flip meaning between renders. Picking colors by index into a color cycle, rather than mapping each category to its own fixed color, means the same species can get a different color depending on what order the data happens to be sorted in:

colors_by_position = [plt.cm.tab10(i) for i in range(3)]
asc = dict(zip(means.sort_values().index, colors_by_position))
desc = dict(zip(means.sort_values(ascending=False).index, colors_by_position))
asc["Gentoo"] == desc["Gentoo"]
False

Sorted ascending, Gentoo lands last and gets the third cycle color; sorted descending, it lands first and gets the first color instead — the same species, two different colors, purely from row order. The palette section above already avoids this: map each category to a fixed color in a dictionary once, and every chart using that dictionary shows the same species in the same color, however the rows are sorted.

A legend that repeats a direct annotation is duplicated information, not extra clarity. Once every bar is labeled with its own species name, a legend box listing the same three names again isn’t adding anything:

legend_labels = ["Adelie", "Chinstrap", "Gentoo"]
annotation_labels = ["Adelie", "Chinstrap", "Gentoo"]
set(legend_labels) == set(annotation_labels)
True

Drop the legend and keep the direct labels — they sit closer to the data, so the reader’s eyes never leave the chart to decode it.

A savefig() call with no dpi argument defaults to the figure’s on-screen resolution, not a print-ready one. Matplotlib’s own default is 100 — perfectly fine for a quick look in a notebook, quietly wrong for anything meant to be enlarged:

fig2, ax2 = plt.subplots(figsize=(6, 4))
ax2.bar(means.index, means.values, color=colors)
fig2.savefig("default-dpi.png")  # no dpi kwarg
default: (600, 400) pixels
dpi=300: (2070, 1318) pixels

Same chart, same figsize, but roughly a sixth of the pixel area of the dpi=300 version from the saving section above. On a laptop screen you’d never notice; printed at full size or dropped into a PDF next to real print-resolution images, it visibly softens.

Wrapping Up

One chart, six deliberate decisions:

  • Cut chart junk first — spines and gridlines that don’t help reading get removed by default, not added by default.
  • Pick color on purpose — a small qualitative palette for categories, a sequential colormap for ordered or continuous values, never the default cycle by accident.
  • plt.style.use() — the fast global option for exploratory work; scope it with plt.style.context() if you don’t want it to outlive the one chart.
  • Annotate the point that matters directly — it’s faster to read than a legend and it can’t drift out of sync with the data.
  • Save at a real DPIdpi=300 and bbox_inches="tight" for raster output meant for print or slides; SVG when it just needs to stay crisp on the web.

If you want to go further with matplotlib fundamentals — titles, labels, line styles, subplots, and grid layouts from the ground up — the Customizing Plots lessons in our free Python for Data Analytics course build on exactly the techniques in this post.

More tutorials