24 min read

> "If you find yourself writing the same matplotlib customization code twice, put it in a function. If you find yourself writing the same function twice, put it in a style sheet."

Learning Objectives

  • Apply custom colors to any chart element using hex codes, named colors, RGB tuples, and colormap indexing
  • Create and apply matplotlib style sheets for consistent theming across multiple figures
  • Configure every text element — title, subtitle, axis labels, tick labels, annotations — with font properties
  • Customize legends: position, number of columns, handle length, font size, title, and legend removal
  • Configure spines, ticks, and gridlines for a clean, professional appearance
  • Build a reusable style function that applies a consistent corporate or academic look to any figure
  • Apply all design principles from Part II using matplotlib API calls
  • Export figures in publication-quality formats with correct DPI, font embedding, and vector format settings
  • Distinguish between one-off customization and systematic styling, and know when to reach for each

Chapter 12: Customization Mastery: Colors, Styles, Labels, Legends, and Themes

"If you find yourself writing the same matplotlib customization code twice, put it in a function. If you find yourself writing the same function twice, put it in a style sheet." — A paraphrase of standard Python advice, applied to visualization


Chapter 10 taught you matplotlib's architecture. Chapter 11 taught you the five essential chart types. You can produce a line chart, a bar chart, a scatter plot, a histogram, or a box plot using the canonical fig, ax = plt.subplots() pattern, and you know the most important parameters for each chart type. You have, in effect, completed the "beginner" phase of matplotlib. The charts you produce are correct, they use the right chart type for the right question, and they load and display without errors.

They are also ugly.

The ugliness is not your fault. It is not matplotlib's fault, exactly, either. The defaults are chosen to produce reasonable output for any input, which means they are optimized for "will not break" rather than "will look professional." The ugly climate plot you made in Chapter 10 and the five ugly climate chart types you made in Chapter 11 are correct, but they violate nearly every principle from Parts I and II:

  • The titles are descriptive ("Temperature Anomaly"), not action titles that state a finding.
  • The axis labels are minimal and missing units.
  • The top and right spines are still there.
  • The gridlines are too heavy or absent (depending on the chart type).
  • The colors are default matplotlib blue, not chosen deliberately.
  • The tick labels are raw numbers, not formatted for human reading.
  • There is no source attribution.
  • There are no annotations calling out specific features.
  • The fonts are matplotlib's default DejaVu Sans, not a deliberate typographic choice.

Every one of these is fixable. Every one is a method call on an Axes object, a parameter to a plot method, or a line in a style sheet. This chapter is about the fixes. By the end of it, you will be able to take any chart from Chapter 11 and transform it into a publication-quality figure that meets every standard from Chapters 6 through 9. You will also know how to package the fixes into a style system so that you do not have to apply them chart by chart forever — you build the style once and apply it automatically to every subsequent chart.

The chapter is long because matplotlib customization is broad. There is no single method to "make your chart look good"; there are dozens of methods for specific aspects (colors, fonts, spines, ticks, gridlines, legends, annotations). But the methods follow patterns, and once you have seen a few of them, the rest become easy to find through the API reference. By the end of the chapter, you should be able to open a default matplotlib chart and list the specific customizations you would apply in under a minute.

The threshold concept of the chapter is that professional matplotlib is systematic, not ad hoc. The goal is not to tweak individual charts; it is to build a style system (style sheet, reusable functions, rcParams) that makes every chart consistent by default. This shift — from per-chart tweaking to system-level design — is the mark of a practitioner who has moved past the "I am learning matplotlib" phase into the "I use matplotlib" phase. Section 12.13 covers the climate-plot transformation in detail. Section 12.14 introduces the style-system approach. Both are essential.

One practical note: this chapter has more code than any previous chapter. Type it. Run it. Modify it. Matplotlib customization is a skill that only compounds with practice, and the specific methods and parameters only become familiar through repetition. The exercises at the end of the chapter are designed to give you that repetition — do at least five of them, and ideally all of them.


12.1 Color Specification: Hex, Named, RGB, and Colormap Indexing

Every chart type in Chapter 11 accepted a color parameter. We used it briefly and moved on. This section goes deeper: how matplotlib specifies colors, what formats it accepts, and how to use colormaps effectively.

The Four Ways to Specify a Color

matplotlib accepts colors in four formats:

1. Named colors. matplotlib supports over 140 named colors, including the X11/CSS4 color names ("red", "blue", "green", "steelblue", "darkorange", "seagreen", etc.). This is the most readable format for common colors:

ax.plot(x, y, color="steelblue")
ax.bar(categories, values, color="darkorange")

The complete list is at matplotlib.org/stable/gallery/color/named_colors.html.

2. Hex codes. Six-character hex codes with a leading #, matching CSS and HTML conventions:

ax.plot(x, y, color="#1f77b4")  # matplotlib's default blue
ax.plot(x, y, color="#d62728")  # a warm red

Hex codes are the standard way to specify colors from a design system or a brand style guide. They are precise and portable — the same hex code produces the same color in matplotlib, CSS, Figma, Adobe Illustrator, and any other tool.

3. RGB or RGBA tuples. Three or four floats between 0 and 1:

ax.plot(x, y, color=(0.12, 0.47, 0.71))         # RGB: matplotlib blue
ax.plot(x, y, color=(0.12, 0.47, 0.71, 0.5))    # RGBA with 50% alpha

The RGBA format lets you specify transparency inline with the color, which is sometimes cleaner than using a separate alpha parameter.

4. Grayscale floats. A single float between 0 and 1 for grayscale:

ax.plot(x, y, color="0.5")  # medium gray
ax.plot(x, y, color="0.8")  # light gray

Pass the value as a string. This is a useful shortcut for gray reference lines, gridlines, and similar elements.

Matplotlib's Default Color Cycle

When you plot multiple lines without specifying colors, matplotlib uses a color cycle — a predefined sequence of colors that each new line draws from. The default cycle as of matplotlib 2.0 is called "tab10" (from Tableau's old color palette) and contains ten colors:

#1f77b4 (blue)
#ff7f0e (orange)
#2ca02c (green)
#d62728 (red)
#9467bd (purple)
#8c564b (brown)
#e377c2 (pink)
#7f7f7f (gray)
#bcbd22 (olive)
#17becf (cyan)

These colors are reasonable — they are colorblind-distinguishable for most types of color blindness and moderately well-chosen — but they are not the best choice for every chart. For publication-quality work, you should override the default cycle with a deliberate palette:

import matplotlib.pyplot as plt
from cycler import cycler

custom_colors = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"]
plt.rcParams["axes.prop_cycle"] = cycler(color=custom_colors)

This sets the default color cycle to your custom list. Every subsequent plot (within the current session, unless you reset) will use these colors in order.

Using Colormaps

A colormap (or cmap) is a continuous mapping from a numerical value to a color. Colormaps are what ax.scatter(..., c=values, cmap="viridis") uses internally. You can also index into a colormap explicitly to get specific colors:

import matplotlib.cm as cm

# Get 5 colors from the viridis colormap, evenly spaced
colors = cm.viridis([0, 0.25, 0.5, 0.75, 1.0])

for i, group in enumerate(groups):
    ax.plot(x, group, color=colors[i], label=f"Group {i}")

Each colors[i] is an RGBA tuple. This is how you get a coordinated set of colors for a multi-series chart: index into a colormap at evenly-spaced positions and use each color for one series.

When to use sequential colormaps (viridis, plasma, cividis, inferno): for data with a natural order (time, intensity, temperature, count).

When to use diverging colormaps (RdBu, coolwarm, RdYlBu): for data organized around a meaningful midpoint (anomalies, differences from baseline, gain-loss).

When to use qualitative colormaps (tab10, Set2, Paired): for categorical data where the categories have no inherent order.

These are the same three categories from Chapter 3. matplotlib's colormap names are the implementation of the Chapter 3 principles.

Applying Chapter 3 Palettes

For the climate chart, a diverging palette makes sense because temperature anomalies have a natural midpoint (the baseline). A reasonable choice: use RdBu_r (reversed RdBu, so warm is red) for a heatmap or a scatter colored by anomaly, and use a specific red-orange (#d62728 or #cc5500) for a single-series line chart where the message is "temperatures are rising."

For the Meridian Corp chart (five product lines), a qualitative palette is appropriate because the product lines have no inherent order. Avoid the default "tab10" for publication and use a curated qualitative palette instead — perhaps the "Set2" palette from ColorBrewer, or a custom palette designed to match a corporate brand.


12.2 Style Sheets: Built-in Themes and Custom Styles

Style sheets are matplotlib's way of applying a consistent look across many charts with a single command. A style sheet is a set of rcParams (discussed in the next section) bundled as a named theme that you can activate globally.

Using Built-in Style Sheets

matplotlib ships with several built-in style sheets. Apply them with:

import matplotlib.pyplot as plt

plt.style.use("ggplot")       # R/ggplot2 style: gray background, white gridlines
plt.style.use("seaborn-v0_8") # seaborn-style defaults
plt.style.use("fivethirtyeight")  # FiveThirtyEight-style (thick lines, gray backgrounds)
plt.style.use("bmh")          # style from the "Bayesian Methods" book
plt.style.use("dark_background")  # White-on-black theme
plt.style.use("classic")      # matplotlib 1.x defaults

The full list of built-in styles is available via plt.style.available:

print(plt.style.available)

Activating a style sheet applies the theme to every subsequent plot in the session. The theme affects colors, fonts, spine visibility, gridline appearance, and many other defaults. It is the fastest way to make your charts look "different from the matplotlib default" without customizing anything individually.

Mixing Style Sheets

You can apply multiple style sheets, with later styles overriding earlier ones:

plt.style.use(["seaborn-v0_8-white", "seaborn-v0_8-paper"])

This applies the "white" base (clean white background) and then the "paper" variant (smaller fonts, tighter layout — optimized for print). Multi-style composition is useful when you want the base aesthetic of one theme combined with the specific tweaks of another.

Creating a Custom Style Sheet

For your own house style, create a .mplstyle file — a plain text file with rcParams settings:

# my_style.mplstyle

# Fonts
font.family: sans-serif
font.sans-serif: Inter, Helvetica, Arial, sans-serif
font.size: 11

# Figure
figure.figsize: 10, 6
figure.dpi: 100
savefig.dpi: 300
savefig.bbox: tight

# Axes
axes.titlesize: 14
axes.titleweight: semibold
axes.labelsize: 11
axes.spines.top: False
axes.spines.right: False
axes.grid: True
axes.grid.axis: y
axes.axisbelow: True

# Ticks
xtick.labelsize: 9
ytick.labelsize: 9
xtick.direction: out
ytick.direction: out

# Gridlines
grid.color: cccccc
grid.linewidth: 0.5
grid.linestyle: -

# Lines
lines.linewidth: 2

# Colors (custom cycle)
axes.prop_cycle: cycler('color', ['1f77b4', 'ff7f0e', '2ca02c', 'd62728'])

Save this as my_style.mplstyle in a location accessible to Python. Apply it with:

plt.style.use("/path/to/my_style.mplstyle")

Or, if you place it in matplotlib's style directory (~/.config/matplotlib/stylelib/my_style.mplstyle on Linux/Mac), you can reference it by name:

plt.style.use("my_style")

This is the single highest-value investment you can make in matplotlib productivity: build a personal style sheet once, and every chart you produce inherits your preferences automatically.

Style Sheets as a Systematic Approach

The style-sheet approach is the first step toward the threshold concept of this chapter: professional matplotlib is systematic. Instead of customizing colors, fonts, spines, and gridlines chart by chart, you make those decisions once in a style file and apply them everywhere. Every chart you produce starts with the right defaults. The time cost of building the style sheet is paid once; the benefit compounds across every subsequent chart.


12.3 rcParams: matplotlib's Global Configuration

Behind every style sheet is matplotlib's rcParams dictionary — a global configuration object that holds every default setting. You can read and modify rcParams directly:

import matplotlib as mpl

# Read a setting
print(mpl.rcParams["font.size"])  # 10.0

# Set a setting
mpl.rcParams["font.size"] = 12
mpl.rcParams["axes.titlesize"] = 14
mpl.rcParams["axes.spines.top"] = False
mpl.rcParams["axes.spines.right"] = False

Every setting you change in rcParams affects subsequent plots. The settings persist until you change them again or reset with mpl.rcdefaults().

The Context Manager Pattern

Sometimes you want to apply specific rcParams to one chart without affecting the rest of the session. The plt.rc_context context manager lets you do this:

with plt.rc_context({"font.size": 14, "axes.titlesize": 18}):
    fig, ax = plt.subplots()
    ax.plot(x, y)
    ax.set_title("Temporarily Larger Fonts")
    fig.savefig("temp.png")

# After the with block, rcParams revert to their previous values

Use the context manager when you need a non-default style for a specific chart but do not want to permanently change the global state. This is the matplotlib equivalent of a try/finally block for rcParams.

Common rcParams to Override

A handful of rcParams produce disproportionate improvements:

mpl.rcParams.update({
    # Fonts
    "font.family": "sans-serif",
    "font.sans-serif": ["Inter", "Helvetica", "Arial"],
    "font.size": 11,

    # Figure
    "figure.dpi": 100,
    "savefig.dpi": 300,
    "savefig.bbox": "tight",

    # Axes
    "axes.spines.top": False,
    "axes.spines.right": False,
    "axes.titlesize": 14,
    "axes.titleweight": "semibold",
    "axes.labelsize": 11,

    # Gridlines
    "axes.grid": True,
    "axes.grid.axis": "y",
    "grid.linestyle": "-",
    "grid.linewidth": 0.5,
    "grid.alpha": 0.6,

    # PDF font embedding (for publication)
    "pdf.fonttype": 42,
    "ps.fonttype": 42,
})

Set these once at the top of your script or in a utility function, and every chart inherits them. The specific values are a matter of personal taste, but these are a reasonable starting point that implements the declutter and typography principles from Chapters 6 and 7.


12.4 Typography: Fonts, Sizes, and Weights

Chapter 7 established the typographic principles for data visualization: use a single legible sans-serif font family, establish a size hierarchy, use weight for emphasis, align text meaningfully, leave whitespace. This section shows how to apply those principles through matplotlib's text-related APIs.

Setting the Font Family

Font family is set through rcParams or directly on text elements:

# Globally
mpl.rcParams["font.family"] = "sans-serif"
mpl.rcParams["font.sans-serif"] = ["Inter", "Source Sans Pro", "Helvetica", "Arial"]

# For one specific text element
ax.set_title("My Chart", fontfamily="Inter", fontsize=16, fontweight="semibold")

The font.sans-serif list is a fallback chain: matplotlib tries the first font, and if it is not available on the system, falls back to the next. Include 3-5 fonts in the chain, with matplotlib's default DejaVu Sans implicit at the end.

Using Custom Fonts

To use a font that matplotlib does not know about (e.g., a font file you downloaded), register it with font_manager:

import matplotlib.font_manager as fm

# Add a custom font file
font_path = "/path/to/Inter-Regular.ttf"
fm.fontManager.addfont(font_path)

# Now you can reference it by name
mpl.rcParams["font.family"] = "Inter"

This is useful when you need a specific font that is not installed on the system (e.g., a brand font for corporate charts). The font file follows the chart into any environment where the font is registered.

Title, Subtitle, and Axis Labels

The standard methods for chart text:

ax.set_title("Action Title Stating the Finding", fontsize=14, fontweight="semibold", loc="left", pad=10)
ax.set_xlabel("Year", fontsize=11)
ax.set_ylabel("Temperature Anomaly (°C)", fontsize=11)

Key parameters:

  • fontsize: the size in points.
  • fontweight: "normal", "bold", "semibold", "light".
  • loc: text alignment — "left", "center", "right". For titles, left-alignment is recommended (per Chapter 7) to create a visible edge with the y-axis.
  • pad: the space between the title and the Axes in points. Default is small; increase for more breathing room.
  • fontfamily: override the default font family for this specific element.

Subtitles via fig.suptitle or Text Annotations

matplotlib does not have a built-in "subtitle" method at the Axes level. To add a subtitle, you can use fig.suptitle for the whole figure:

fig.suptitle("Main Title", fontsize=16, fontweight="bold", y=0.98)
fig.text(0.125, 0.92, "Subtitle with additional context", fontsize=11, color="gray")
ax.set_title("")  # clear the Axes title since we are using figure-level titles

Or, use ax.set_title() with multi-line text:

ax.set_title("Main Title\nSubtitle", fontsize=14, loc="left")

Or, use ax.text to place the title components manually:

ax.text(0.0, 1.08, "Action Title", transform=ax.transAxes, fontsize=14, fontweight="semibold")
ax.text(0.0, 1.03, "Subtitle with context", transform=ax.transAxes, fontsize=11, color="gray")

The transform=ax.transAxes parameter tells matplotlib to interpret the coordinates as "Axes-relative" (0-1) rather than data coordinates. (0, 1.08) is just above the top-left of the plotting area, which is a natural title position.

Tick Label Formatting

Tick labels are configured through tick_params:

ax.tick_params(
    axis="both",        # "x", "y", or "both"
    which="major",      # "major", "minor", or "both"
    labelsize=10,       # font size
    colors="gray",      # label color
    length=4,           # tick mark length
    width=0.5,          # tick mark width
    direction="out",    # "in", "out", or "inout"
    pad=4,              # space between tick and label
)

For specific tick formatting (thousands separators, percentage, currency), use tick formatters (see Section 12.5).


12.5 Tick Formatting: Making Numbers Readable

Chapter 7 emphasized that tick labels should be formatted for human reading: thousands separators, natural units, appropriate decimal places. matplotlib's matplotlib.ticker module provides the tools.

Common Formatters

from matplotlib.ticker import FuncFormatter, StrMethodFormatter, PercentFormatter

# Thousands separators: 1000 -> 1,000
ax.yaxis.set_major_formatter(StrMethodFormatter("{x:,.0f}"))

# Percentages: 0.75 -> 75%
ax.yaxis.set_major_formatter(PercentFormatter(xmax=1.0))

# Custom function: 5000000 -> $5.0M
def dollars_to_millions(x, pos):
    return f"${x/1e6:.1f}M"

ax.yaxis.set_major_formatter(FuncFormatter(dollars_to_millions))

# Degrees: 25 -> 25°C
ax.yaxis.set_major_formatter(StrMethodFormatter("{x}°C"))

The FuncFormatter is the most flexible — you write a Python function that takes a value and a tick position and returns a formatted string. This lets you implement any formatting logic you want.

Date Formatting

For datetime x-axes:

import matplotlib.dates as mdates

ax.xaxis.set_major_locator(mdates.YearLocator(10))  # tick every 10 years
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))  # format as 4-digit year

The mdates.YearLocator(N) places ticks every N years. Other locators include MonthLocator, WeekdayLocator, and AutoDateLocator (matplotlib chooses based on the data range).

Removing Ticks Entirely

Sometimes you want no ticks at all (for minimalist charts like the warming stripes):

ax.set_xticks([])
ax.set_yticks([])

# Or remove only the labels but keep the tick positions:
ax.set_xticklabels([])
ax.set_yticklabels([])

This is the extreme declutter move — use it deliberately, not by accident.


12.6 Spines and Gridlines: The Chapter 6 Declutter in Code

Chapter 6 taught the declutter procedure: remove structural chart-junk like the top and right spines, lighten gridlines, simplify tick marks. This section shows the matplotlib code.

Removing Spines

ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)

Two lines per chart, applied to every chart. This single change produces a dramatic improvement in visual cleanliness. You can add it to your style sheet as well (axes.spines.top: False) so it applies to every chart automatically.

For even cleaner designs, remove all four spines and rely on tick labels for context:

for spine in ["top", "right", "bottom", "left"]:
    ax.spines[spine].set_visible(False)

This is extreme but can work for highly minimalist designs.

Lightening Spines

Instead of removing, lighten:

ax.spines["bottom"].set_color("gray")
ax.spines["bottom"].set_linewidth(0.8)
ax.spines["left"].set_color("gray")
ax.spines["left"].set_linewidth(0.8)

This keeps the spines present but makes them visually recessive, so they support the chart without competing with the data.

Offsetting Spines

For a more sophisticated look, offset the spines from the plotting area:

ax.spines["left"].set_position(("outward", 10))
ax.spines["bottom"].set_position(("outward", 10))

The spines are now drawn 10 points outside the plotting area, creating a visible gap. This is a subtle design touch that makes the chart feel less boxed-in.

Gridlines

ax.grid(True, axis="y", linestyle="-", linewidth=0.5, color="#cccccc", alpha=0.8, zorder=0)
ax.set_axisbelow(True)  # put the grid behind the data

The axis="y" parameter draws only horizontal gridlines (appropriate for bar charts and time series where the y-axis is the quantitative dimension). color="#cccccc" is a light gray that recedes. linewidth=0.5 is thin. alpha=0.8 adds slight transparency. set_axisbelow(True) is important — it ensures the gridlines are rendered below the data, so the data is always visible on top.


12.7 Annotations: Callouts, Arrows, and Text Boxes

Chapter 7 said annotations are "the text that does the most work per word." matplotlib's annotation methods support arrows, text boxes, and direct labels.

Simple Text Annotation

ax.annotate(
    "2016: +1.01°C, warmest year",
    xy=(2016, 1.01),       # data coordinates of the annotation target
    xytext=(2000, 1.2),    # text position
    fontsize=10,
    color="#d62728",
    arrowprops=dict(
        arrowstyle="->",
        color="gray",
        lw=0.8,
    ),
)

The xy parameter is the target point (where the arrow head points). The xytext parameter is where the text is placed. If you set them to the same point, no arrow is drawn and the text sits directly at the target.

Text Without an Arrow

ax.text(2016, 1.01, "2016: +1.01°C", fontsize=10, color="#d62728", ha="center", va="bottom")

ha is horizontal alignment ("left", "center", "right"), and va is vertical alignment ("bottom", "center", "top"). Combined, they position the text relative to the anchor point.

Text in a Box

For annotations that need visual separation from the background:

ax.text(
    2010, 0.5,
    "Key Finding:\nWarming Accelerated\nAfter 1980",
    fontsize=11,
    ha="left",
    va="center",
    bbox=dict(
        boxstyle="round,pad=0.5",
        facecolor="white",
        edgecolor="gray",
        linewidth=0.5,
    ),
)

The bbox parameter draws a box around the text. boxstyle="round,pad=0.5" makes it a rounded box with padding; other options include "square" and "rarrow" (right-pointing arrow).

Shaded Regions

For annotating a range on the x-axis:

ax.axvspan(2008, 2010, alpha=0.2, color="gray", label="Great Recession")
ax.text(2009, 0.8, "2008-09\nrecession", ha="center", fontsize=9, color="gray")

axvspan fills a vertical band between two x-values. axhspan does the same horizontally. These are useful for marking time periods, events, or threshold regions.

Horizontal and Vertical Reference Lines

ax.axhline(0, color="gray", linewidth=0.8, linestyle="--", alpha=0.8)
ax.axvline(1980, color="gray", linewidth=0.8, linestyle="--", alpha=0.8)

Quick one-liners for baseline references and timeline markers.


12.8 Legends: Placement, Columns, and Removal

matplotlib's legend handling is powerful but verbose. The most important patterns:

Basic Legend

ax.plot(x, y1, label="Series A")
ax.plot(x, y2, label="Series B")
ax.legend()  # creates the legend using the labels

The label parameter on each plot call is what ax.legend() picks up. If you do not set labels, no legend entries appear.

Legend Position

ax.legend(loc="upper right")        # default
ax.legend(loc="best")               # matplotlib picks the best corner
ax.legend(loc="center left", bbox_to_anchor=(1.0, 0.5))  # outside the plot
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.15), ncol=3)  # below

The loc parameter takes one of several string values ("upper right", "lower left", "center", "best", etc.). The bbox_to_anchor parameter lets you anchor the legend to a specific point in axes-relative coordinates, useful for placing legends outside the plotting area.

Multi-Column Legend

ax.legend(ncol=3)  # 3 columns

For legends with many entries, multiple columns save vertical space.

Removing the Legend Frame

ax.legend(frameon=False)  # no box around the legend

The default legend has a boxed frame. For a cleaner look, set frameon=False and let the legend entries float without a border.

Legend Title and Font Size

ax.legend(title="Product Line", fontsize=10, title_fontsize=11)

A legend title can replace an axis label when the legend itself provides the grouping context.

Direct Labeling Instead of a Legend

Per Chapter 7, direct labels are often preferable to legends. To direct-label a line chart:

for i, (label, series) in enumerate(data.items()):
    ax.plot(x, series, color=colors[i])
    ax.text(x[-1], series[-1], f" {label}", fontsize=10, color=colors[i], va="center")

The text call places the label at the right end of each line, with a leading space for separation. No legend is needed.


12.9 Exporting Publication-Quality Figures

Chapter 10 covered savefig basics; this section covers the details for publication-quality output.

Raster Output (PNG)

fig.savefig(
    "chart.png",
    dpi=300,                 # print quality
    bbox_inches="tight",     # crop to content
    facecolor="white",       # white background
    pad_inches=0.1,          # small padding around the content
)

Vector Output (SVG, PDF)

fig.savefig("chart.svg", bbox_inches="tight")  # editable in Illustrator, Figma
fig.savefig("chart.pdf", bbox_inches="tight")  # print publications

PDF Font Embedding

For publication PDFs, set the font type to 42 (TrueType) so fonts embed correctly:

mpl.rcParams["pdf.fonttype"] = 42
mpl.rcParams["ps.fonttype"] = 42

Without this, some journals will reject PDF figures because the default Type 3 fonts are not supported by their publishing systems. Set these settings once at the top of your script and forget about them.

Transparent Backgrounds

For charts that will be overlaid on other content:

fig.savefig("chart.png", transparent=True, dpi=300)

The figure background becomes transparent. Useful for embedding in slides with colored backgrounds.


12.10 Building a Reusable Style Function

The culmination of this chapter is the style function — a reusable Python function that applies your house style to any Axes. Instead of customizing every chart manually, you write the customization once and call the function on each chart.

A Basic Style Function

def apply_clean_style(ax):
    """Apply a clean, publication-ready style to a matplotlib Axes."""
    # Spines
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    ax.spines["left"].set_color("gray")
    ax.spines["bottom"].set_color("gray")
    ax.spines["left"].set_linewidth(0.8)
    ax.spines["bottom"].set_linewidth(0.8)

    # Ticks
    ax.tick_params(
        axis="both",
        which="major",
        labelsize=10,
        colors="gray",
        length=4,
        width=0.5,
        direction="out",
    )

    # Gridlines (horizontal only)
    ax.grid(True, axis="y", linestyle="-", linewidth=0.5, color="#cccccc", alpha=0.6, zorder=0)
    ax.set_axisbelow(True)

    return ax

Usage:

fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(x, y, color="#d62728", linewidth=2)
apply_clean_style(ax)
ax.set_title("Cleaned-Up Chart", fontsize=14, loc="left", fontweight="semibold")
ax.set_ylabel("Value")

The style function centralizes the customization logic. When you want to change the house style (different gray shade, different tick direction), you change the function once and every chart updates.

A More Elaborate Style Function

def style_publication(ax, title=None, xlabel=None, ylabel=None, source=None):
    """Apply full publication styling to an Axes including title, labels, and source."""
    # Base style
    apply_clean_style(ax)

    # Title (action style, left-aligned)
    if title:
        ax.set_title(title, fontsize=14, loc="left", fontweight="semibold", pad=12)

    if xlabel:
        ax.set_xlabel(xlabel, fontsize=11)
    if ylabel:
        ax.set_ylabel(ylabel, fontsize=11)

    # Source attribution at the bottom right of the figure
    if source:
        fig = ax.get_figure()
        fig.text(0.99, 0.01, source, fontsize=8, color="gray", ha="right", va="bottom", style="italic")

    return ax

Usage:

fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(years, anomaly, color="#d62728", linewidth=1.5)
style_publication(
    ax,
    title="Global Temperatures Have Risen 1.2°C Since 1900",
    ylabel="Temperature Anomaly (°C)",
    source="Source: NASA GISS. Baseline: 1951-1980.",
)
fig.savefig("climate_polished.png", dpi=300, bbox_inches="tight")

Six lines of chart code plus a single function call produce a publication-quality figure. The discipline has been encapsulated in the function, and using it is effortless.

This is the threshold concept in action: build a style system once, apply it automatically forever. Every chart you produce from this point forward should use your style function (or a style sheet) by default.


12.11 Themed Examples: Three Looks for the Same Chart

The same climate data can be rendered in multiple "house styles," each appropriate for a different audience or context. This section walks through three quick variants to show how style choices cascade through a chart.

Variant A: Academic paper. A conservative, minimal style suitable for a journal submission. Small fonts, black-and-white where possible, explicit source attribution, no decorative color.

plt.style.use("default")
with plt.rc_context({"font.family": "serif", "font.serif": ["Times New Roman"], "font.size": 9}):
    fig, ax = plt.subplots(figsize=(3.5, 2.5))  # single-column journal width
    ax.plot(years, anomaly, color="black", linewidth=0.8)
    ax.axhline(0, color="gray", linewidth=0.5, linestyle="--")
    ax.set_ylabel("Temperature anomaly (°C)", fontsize=8)
    ax.set_xlabel("Year", fontsize=8)
    ax.set_title("Figure 1: Global temperature record", fontsize=9)
    for spine in ["top", "right"]:
        ax.spines[spine].set_visible(False)
    fig.savefig("climate_paper.pdf", dpi=300, bbox_inches="tight")

Serif fonts, black ink, small single-column size, descriptive figure caption. This is how figures look in most scientific journals.

Variant B: News graphics. A modern data-journalism style with an action title, bold color, and a more casual layout. Suitable for a newspaper website or a blog post.

with plt.rc_context({"font.family": "sans-serif", "font.sans-serif": ["Inter"]}):
    fig, ax = plt.subplots(figsize=(12, 5))
    ax.fill_between(years, anomaly, 0, where=(anomaly >= 0), color="#d62728", alpha=0.6)
    ax.fill_between(years, anomaly, 0, where=(anomaly < 0), color="#1f77b4", alpha=0.6)
    ax.plot(years, anomaly, color="black", linewidth=0.8)
    ax.axhline(0, color="black", linewidth=0.5)
    ax.set_title("The World Has Warmed 1.2°C Since 1900", fontsize=18, loc="left", fontweight="semibold")
    ax.set_ylabel("Anomaly (°C)", fontsize=11)
    for spine in ["top", "right"]:
        ax.spines[spine].set_visible(False)
    fig.text(0.125, 0.02, "Source: NASA GISS", fontsize=9, color="gray")
    fig.savefig("climate_news.png", dpi=200, bbox_inches="tight")

The fill_between calls shade the above-zero and below-zero regions in different colors, creating a more visually striking chart. The action title is large and left-aligned. The file is PNG at moderate resolution for web.

Variant C: Slide deck. A high-contrast dark theme suitable for projection on a conference screen. Large fonts, bold colors, minimal text.

with plt.rc_context({
    "font.size": 16,
    "text.color": "white",
    "axes.labelcolor": "white",
    "xtick.color": "white",
    "ytick.color": "white",
}):
    fig, ax = plt.subplots(figsize=(14, 6), facecolor="#1a1a1a")
    ax.set_facecolor("#1a1a1a")
    ax.plot(years, anomaly, color="#ff6b35", linewidth=3)
    ax.axhline(0, color="white", linewidth=1, linestyle="--", alpha=0.5)
    ax.set_title("1.2°C and Rising", fontsize=28, color="white", loc="left")
    ax.set_ylabel("Temperature Anomaly (°C)", fontsize=14)
    for spine in ["top", "right"]:
        ax.spines[spine].set_visible(False)
    for spine in ["bottom", "left"]:
        ax.spines[spine].set_color("white")
    fig.savefig("climate_slide.png", dpi=150, bbox_inches="tight", facecolor="#1a1a1a")

Dark background, bright line color, very large title, fewer elements. The chart is unreadable in print but ideal for a projected slide at the front of a room.

Three variants, same underlying data, each appropriate for a specific context. The ability to produce multiple contextualized versions of the same chart without rewriting the data loading or analysis code is exactly the payoff of the style-system approach. Store each style as a function or a style file, and calling it becomes a one-line decision about the output context.

12.12 Advanced Colormap Techniques

The cmap parameter on scatter and imshow calls is just the entry point to matplotlib's colormap system. A few deeper techniques are worth knowing for serious data visualization work.

Choosing a Perceptually Uniform Colormap

Chapter 3 established that perceptually uniform colormaps (like viridis, plasma, inferno, cividis, magma) are preferred over the old "jet" rainbow colormap because equal steps in data correspond to equal perceived changes in color. matplotlib's modern default for sequential data is viridis, introduced in matplotlib 2.0 as the default sequential colormap.

import matplotlib.pyplot as plt
import numpy as np

# Sequential: viridis, plasma, inferno, magma, cividis
ax.imshow(data, cmap="viridis")

# Diverging with a natural midpoint: RdBu_r, coolwarm, BrBG, PiYG
ax.imshow(anomaly_data, cmap="RdBu_r", vmin=-2, vmax=2)

# Qualitative: tab10, Set2, Paired, Accent, Dark2
for i, group in enumerate(groups):
    ax.plot(x, group, color=plt.cm.tab10(i))

The vmin and vmax parameters are particularly important for diverging colormaps. Setting them symmetrically around the meaningful midpoint (for example, vmin=-2, vmax=2 around zero) ensures that the white/light color of the diverging palette aligns with the midpoint. Without explicit vmin/vmax, matplotlib auto-scales to the data range, which can put the midpoint of the colormap at the data median rather than at the conceptually meaningful zero.

Truncating and Combining Colormaps

Sometimes the full range of a colormap is too extreme, or you want only the "upper half" of a sequential palette to avoid very pale colors. matplotlib supports truncating colormaps:

import matplotlib.colors as mcolors

def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=256):
    new_cmap = mcolors.LinearSegmentedColormap.from_list(
        f"trunc({cmap.name},{minval:.2f},{maxval:.2f})",
        cmap(np.linspace(minval, maxval, n)),
    )
    return new_cmap

# Use only the darker half of viridis
my_cmap = truncate_colormap(plt.cm.viridis, 0.3, 1.0)
ax.imshow(data, cmap=my_cmap)

This is useful when the default colormap is too washed-out at the low end or too intense at the high end for your specific data distribution.

Creating a Custom Colormap From Colors

For brand-specific or publication-specific palettes, you can build a custom colormap from a list of colors:

from matplotlib.colors import LinearSegmentedColormap

# A custom sequential palette from light yellow to dark red
colors = ["#ffffcc", "#ffeda0", "#feb24c", "#fc4e2a", "#bd0026"]
custom_cmap = LinearSegmentedColormap.from_list("custom_heat", colors)

ax.imshow(data, cmap=custom_cmap)

The from_list method interpolates smoothly between the provided colors. This is how you implement a brand palette or a publication-specific color scheme as a reusable colormap. Register it with matplotlib.colormaps.register() if you want to reference it by name later.

Colorbar Customization

When a chart uses a colormap, the colorbar is how readers decode the color-to-value mapping. Default colorbars are functional but can be customized for polish:

cbar = fig.colorbar(
    scatter,
    ax=ax,
    shrink=0.8,              # shorter than the full axes height
    aspect=30,               # wider than the default
    pad=0.02,                # distance from the axes
    label="Temperature (°C)",
    extend="both",           # arrows at both ends for clipped values
)
cbar.ax.tick_params(labelsize=9, colors="gray")
cbar.outline.set_visible(False)

A well-customized colorbar looks intentional rather than tacked on — it matches the rest of the chart's styling and takes up just enough space to be readable without dominating.

12.12 Beyond rcParams: The Axes-Level Styling Pattern

rcParams is global, which means it affects every chart in the session. Sometimes you want chart-specific styling that does not persist — for example, one chart with a dark background while the rest of the session uses a light background, or one chart with larger fonts for a slide-deck export while the rest are sized for print. The Axes-level styling pattern handles these cases without polluting global state.

The Context Manager Pattern Revisited

with plt.rc_context({
    "font.size": 14,
    "axes.titlesize": 18,
    "figure.facecolor": "#222222",
    "axes.facecolor": "#222222",
    "text.color": "white",
    "axes.labelcolor": "white",
    "xtick.color": "white",
    "ytick.color": "white",
}):
    fig, ax = plt.subplots(figsize=(12, 5))
    ax.plot(years, anomaly, color="#ffaa00", linewidth=2)
    ax.set_title("Climate Chart (Dark Theme)")
    fig.savefig("dark_theme.png", dpi=300, bbox_inches="tight", facecolor="#222222")

Outside the with block, rcParams revert to whatever they were before. This lets you produce a dark-themed chart without affecting subsequent light-themed charts in the same script. The pattern is especially useful for exporting the same data in multiple themes (light for the report, dark for the slide deck) without duplicating chart construction code.

Chart-Specific Style Functions

When you need per-chart styling that is too complex for a context manager, wrap the chart construction in a function:

def make_slide_chart(years, anomaly):
    """Create a dark-theme slide-ready version of the climate chart."""
    with plt.rc_context({"font.size": 14, "text.color": "white", "axes.labelcolor": "white"}):
        fig, ax = plt.subplots(figsize=(12, 5), facecolor="#222222")
        ax.set_facecolor("#222222")
        ax.plot(years, anomaly, color="#ffaa00", linewidth=2.5)
        for spine in ["top", "right"]:
            ax.spines[spine].set_visible(False)
        for spine in ["bottom", "left"]:
            ax.spines[spine].set_color("white")
        ax.tick_params(colors="white")
        ax.set_title("Global Temperature Trend", color="white")
        ax.set_ylabel("Anomaly (°C)", color="white")
        return fig, ax

# Use it:
fig, ax = make_slide_chart(years, anomaly)
fig.savefig("slide_chart.png", dpi=200, bbox_inches="tight", facecolor="#222222")

The function encapsulates everything about the dark theme. Calling it produces a chart; the caller does not need to know which rcParams are being set. This pattern is how experienced matplotlib users produce multi-theme outputs: one style function per theme, all callable from the same script, each isolated from the others.

12.12 The Publication-Ready Checklist

Before you declare a chart done, walk through a short checklist. Most chart problems become visible when you ask the right questions.

Chapter 4 — ethics: - [ ] Does the y-axis start at zero for bar and area charts? - [ ] Is the time range meaningful and not cherry-picked? - [ ] Is the baseline or comparison group visible? - [ ] Are error bars or confidence bands shown for estimated values?

Chapter 6 — decluttering: - [ ] Are the top and right spines removed (or justified if present)? - [ ] Are the gridlines light enough to recede behind the data? - [ ] Are the tick marks short and thin rather than long and heavy? - [ ] Is the figure border removed? - [ ] Are the colors limited and deliberate?

Chapter 7 — typography and annotation: - [ ] Does the title state a specific finding (action title)? - [ ] Does the subtitle provide essential context (data source, time range, baseline)? - [ ] Do axis labels include units in parentheses? - [ ] Are tick labels formatted for human reading (thousands separators, natural units)? - [ ] Is there at least one annotation calling out a specific feature? - [ ] Is there an on-image source attribution?

Chapter 8 — composition: - [ ] Is the aspect ratio appropriate for the chart type (wide for time series, square for scatter, tall for rankings)? - [ ] If multi-panel, are scales shared where comparison matters? - [ ] If multi-panel, is the hero chart visually prominent?

Chapter 12 — output: - [ ] Is the savefig dpi set to 300 for print or 150 for web? - [ ] Is bbox_inches="tight" set to crop whitespace? - [ ] For PDF output, is pdf.fonttype=42 set for font embedding? - [ ] Does the file open correctly in the target application (browser, Word, Illustrator)?

Apply this checklist to every chart you plan to share. The cost is one minute per chart; the payoff is that your charts consistently meet professional standards without relying on memory.

12.13 The Climate Chart, Polished

Finally, here is the complete transformation of the climate chart — from the ugly Chapter 10 version to a publication-quality figure:

import matplotlib.pyplot as plt
import matplotlib as mpl
import pandas as pd

# Load data
climate = pd.read_csv("climate_data.csv")

# rcParams for the session
mpl.rcParams.update({
    "font.family": "sans-serif",
    "font.sans-serif": ["Inter", "Helvetica", "Arial"],
    "font.size": 11,
    "axes.spines.top": False,
    "axes.spines.right": False,
    "axes.titlesize": 16,
    "axes.titleweight": "semibold",
    "pdf.fonttype": 42,
})

# Create the figure
fig, ax = plt.subplots(figsize=(13, 5))

# Plot the line with a deliberate color and weight
ax.plot(
    climate["year"],
    climate["anomaly"],
    color="#d62728",
    linewidth=1.8,
)

# Reference line at the baseline
ax.axhline(0, color="gray", linewidth=0.8, linestyle="--", alpha=0.7)

# Annotations on record years
ax.annotate(
    "2016: +1.01°C",
    xy=(2016, 1.01),
    xytext=(2000, 1.22),
    fontsize=10,
    color="#8b0000",
    arrowprops=dict(arrowstyle="->", color="gray", lw=0.6),
)

ax.annotate(
    "2023: +1.18°C (record)",
    xy=(2023, 1.18),
    xytext=(2000, 1.4),
    fontsize=10,
    color="#8b0000",
    arrowprops=dict(arrowstyle="->", color="gray", lw=0.6),
)

# Shaded baseline period
ax.axvspan(1951, 1980, alpha=0.15, color="gray")
ax.text(1965, -0.4, "1951-1980\nbaseline", ha="center", fontsize=9, color="gray")

# Title (action title)
ax.set_title(
    "Global Temperatures Are Now 1.2°C Above the 20th-Century Average",
    loc="left",
    pad=14,
)

# Subtitle (as fig.text for precise positioning)
fig.text(
    0.125, 0.90,
    "NASA GISS Surface Temperature Analysis, 1880-2024",
    fontsize=12,
    color="gray",
)

# Axis labels
ax.set_ylabel("Temperature Anomaly (°C)", fontsize=11)
ax.set_xlabel("")  # year is self-explanatory

# Tick formatting
ax.tick_params(axis="both", labelsize=10, colors="gray", length=4, width=0.5)

# Y-axis limits
ax.set_ylim(-0.8, 1.6)

# Gridlines
ax.grid(True, axis="y", linestyle="-", linewidth=0.4, color="#cccccc", alpha=0.6, zorder=0)
ax.set_axisbelow(True)

# Lighten the remaining spines
ax.spines["bottom"].set_color("gray")
ax.spines["left"].set_color("gray")
ax.spines["bottom"].set_linewidth(0.8)
ax.spines["left"].set_linewidth(0.8)

# Source attribution
fig.text(
    0.125, 0.02,
    "Source: NASA Goddard Institute for Space Studies. Baseline: 1951-1980 average.",
    fontsize=8,
    color="gray",
    style="italic",
)

# Save at publication quality
fig.savefig("climate_polished.png", dpi=300, bbox_inches="tight", facecolor="white")

This is what the ugly Chapter 10 chart becomes after everything from this chapter is applied. The chart passes the 5-second test. The action title states the finding. The annotations call out the two record years. The baseline period is shaded for context. The typography is clean. The spines are decluttered. The source is attributed. The colors are deliberate. Every line of the customization code connects to a specific principle from Parts I and II.

This is the culmination of Part III's primary goal: the ability to produce publication-quality matplotlib figures that meet every standard from the first nine chapters.


Chapter Summary

This chapter covered matplotlib customization — the methods, parameters, and patterns for turning a default chart into a publication-quality figure. The core areas: color specification, style sheets, rcParams, typography, tick formatting, spine and gridline management, annotations, legends, and export settings.

The threshold concept is that professional matplotlib is systematic, not ad hoc. Individual chart customization is tedious and error-prone. A style system (style sheet, rcParams, reusable function) lets you make the design decisions once and apply them automatically to every subsequent chart. Building the system is the mark of a practitioner who has moved past learning matplotlib into using it effectively.

The climate chart transformation in Section 12.11 is the concrete demonstration: the same data, the same chart type, transformed from an ugly default output into a publication-quality figure by applying everything from Parts I and II through specific matplotlib method calls. Every line of customization code connects to a principle from the first nine chapters.

Next in Chapter 13: Subplots, GridSpec, and Multi-Panel Figures. You learn how to create the multi-panel layouts that Chapter 8 introduced conceptually — small multiples, hero-plus-supporting arrangements, and complex dashboards — using matplotlib's subplot and GridSpec APIs.


Spaced Review: Concepts from Chapters 1-11

  1. Chapter 3: Which colormap would you choose for a sequential variable, a diverging variable, and a qualitative variable? Name specific matplotlib cmaps for each.

  2. Chapter 4: In Section 12.11's climate chart, how does the code enforce the ethical discipline from Chapter 4 (no truncated axes, visible baseline, no cherry-picking)?

  3. Chapter 6: Which specific matplotlib methods implement the declutter procedure (remove, lighten, simplify)?

  4. Chapter 7: Section 12.11 uses an action title. How does that single choice embody the threshold concept of Chapter 7?

  5. Chapter 8: Section 12.11 is a single chart. For a multi-panel version, what would change about the rcParams and the style function?

  6. Chapter 9: The polished climate chart is a single figure, not a multi-chart story. How would you sequence multiple charts of this quality into a data story? (This is the bridge to dashboards and presentations in Part VII.)