Matplotlib Library

matplotlib has a reputation for being clunky, and the reputation is earned exactly by people fighting the wrong api. there are two: a stateful pyplot layer that mimics matlab (“draw on whatever was touched last”), and an object-oriented core in which every visible thing is an object you hold a reference to. the second is the real library; the first is sugar for one-liners. write fig, ax = plt.subplots() and stay in object land, and most of the clunk evaporates. 𐃏 all code on this page executed for real; figures were written to /tmp and are described rather than embedded, with true file sizes.

the architecture: figure, axes, artist

three nouns carry the whole library:

  • figure β€” the top-level canvas: owns everything, knows its size in inches and its dpi. one window / one file = one figure.
  • axes β€” a plotting region with its own coordinate system: two (or three) axis objects, tick machinery, labels, title, and the data artists drawn inside it. a figure holds any number of axes β€” “subplot” is just an axes on a grid. 𐃏
  • artist β€” the universal base class. lines, patches, text, images, ticks, legends, the axes themselves, the figure itself: everything rendered is an artist, arranged in one containment tree, and every property (colour, alpha, zorder, transform) is settable on any of them.

rendering is a tree walk: the figure asks its children to draw, they ask theirs. customising a plot = locating the right artist in the tree and setting its properties. that single sentence replaces about half of stack overflow.

the containment hierarchy: a Figure owns Axes; each Axes owns its axis machinery and the data artists; every node in the tree is an Artist.

the two apis

plt.plot(...) secretly means “fetch the current axes (creating a figure if none), call .plot on it” β€” global mutable state, fine in a repl, hostile in functions. the OO form makes the state explicit:

import matplotlib
matplotlib.use("Agg")                 # non-interactive backend
import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 2 * np.pi, 200)
fig, ax = plt.subplots(figsize=(6, 4))          # Figure + one Axes
line, = ax.plot(x, np.sin(x), label=r"$\sin x$")
ax.set(xlabel="x", ylabel="y", title="one axes, one artist per thing")
ax.legend()

# walk the artist hierarchy: everything on screen is an Artist
print("figure children :", [type(c).__name__ for c in fig.get_children()])
print("axes has        :", len(ax.lines), "Line2D,",
      len(ax.texts), "Text,", len(ax.get_xticklabels()), "xticklabels")
print("the line artist :", line)
print("its parent axes :", line.axes is ax, "| its figure:", line.figure is fig)

fig.savefig("/tmp/mpl-sine.png", dpi=110)
import os
print("saved /tmp/mpl-sine.png:", os.path.getsize("/tmp/mpl-sine.png"), "bytes")
figure children : ['Rectangle', 'Axes']
axes has        : 1 Line2D, 0 Text, 9 xticklabels
the line artist : Line2D($\sin x$)
its parent axes : True | its figure: True
saved /tmp/mpl-sine.png: 28122 bytes

the resulting png (28 kB): a single blue sine period on a white ground, legend box top-right β€” nothing exotic, but every piece of it is now a named object we could restyle after the fact. note the figure’s children: a background Rectangle and the Axes β€” the tree is real, not a metaphor.

anatomy of a figure

the checklist to run when a plot looks wrong, outermost in:

  • figure: figsize (inches!) and dpi β€” pixels are their product; a “too small fonts” problem is usually a “figure too large” problem.
  • axes position: the rectangle the axes occupies inside the figure (layout engines below).
  • spines: the four box edges; hide or move them (ax.spines["top"].set_visible(False)).
  • axis objects: locators decide where ticks go, formatters decide what they say β€” the right fix for ugly ticks is a Locator or Formatter, never manual strings.
  • data artists: what you drew.
  • text: title, axis labels, annotations β€” all Text artists, all mathtext-capable ($...$).

subplots and layout

plt.subplots(rows, cols) returns the figure and an array of axes β€” the only subplot api worth remembering. the historical pain of overlapping labels is solved by layout engines: layout="constrained" (preferred, a real constraint solver) or layout="tight" (older heuristic). both negotiate space so titles, ticklabels and colourbars stop colliding.

the plot families

choosing the mark is the actual design decision; the code is uniform. one executed tour:

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import os

rng = np.random.default_rng(0)
fig, axs = plt.subplots(2, 3, figsize=(10, 6), layout="constrained")

x = np.linspace(0, 4, 100)
axs[0, 0].plot(x, np.exp(-x) * np.cos(6 * x)); axs[0, 0].set_title("line: ordered x")
axs[0, 1].scatter(rng.normal(size=200), rng.normal(size=200), s=8)
axs[0, 1].set_title("scatter: paired points")
axs[0, 2].bar(["a", "b", "c", "d"], [3, 7, 2, 5]); axs[0, 2].set_title("bar: categories")
axs[1, 0].hist(rng.standard_normal(2000), bins=40); axs[1, 0].set_title("hist: distribution")
img = rng.random((20, 20))
axs[1, 1].imshow(img, cmap="viridis"); axs[1, 1].set_title("imshow: a matrix")
X, Y = np.meshgrid(np.linspace(-2, 2, 80), np.linspace(-2, 2, 80))
axs[1, 2].contour(X, Y, X**2 + 0.5 * Y**2, levels=8)
axs[1, 2].set_title("contour: a scalar field")

fig.savefig("/tmp/mpl-families.png", dpi=110)
print("6 axes in one figure ->", os.path.getsize("/tmp/mpl-families.png"), "bytes")
6 axes in one figure -> 106589 bytes

the saved figure (107 kB) is a \(2 \times 3\) grid, constrained layout keeping all six titles clear of neighbouring ticklabels: a damped cosine line, a gaussian point cloud, four bars, a 40-bin histogram, a viridis heatmap, and eight elliptical contour rings. when to reach for which:

markwhen
plot\(y\) against ordered \(x\) β€” trends, trajectories, functions
scatterpaired observations, no ordering implied; size/colour as extra channels
bara handful of categories (if the x-axis is numeric and dense, you wanted a line)
histone variable’s distribution (bin count is a real modelling choice)
imshowmatrices and images β€” anything indexed by two integers
contour / contourfsmooth scalar fields \(f(x, y)\) β€” level sets, decision boundaries

styling

two mechanisms, layered:

  • rcParams β€” a 322-key dictionary of defaults (font sizes, line widths, colour cycle, dpi …). set globally (plt.rcParams["font.size"] = 11), or temporarily with plt.rc_context({...}).
  • style sheets β€” named rcParams bundles: plt.style.use("ggplot"), 29 shipped (measured below), stackable, and you can ship your own .mplstyle file per project β€” the correct way to make every figure in a thesis agree.

3d and animation, briefly

  • 3d: fig.add_subplot(projection="3d") gives an Axes3D with plot_surface, scatter, plot_wireframe. honest advice: matplotlib’s 3d is a projection of artists, not a scene graph β€” occlusion sorting is approximate (artists are z-sorted per collection, not per fragment), so intersecting surfaces render wrongly. fine for a quick surface, use a real 3d engine for more.
  • animation: FuncAnimation(fig, update, frames=...) calls your update(frame) to mutate artists in place (line.set_ydata(...)), then writes via ffmpeg or pillow. the artist tree pays off again: animation is just property-setting on schedule.

backends

a backend is the renderer behind the artist tree: Agg rasterises to png (the batch/server default β€” this page used it), pdf/svg/ps write vector files, and gui backends (macosx, qt, tk) couple a renderer to a window and event loop. code is backend-agnostic; you select with matplotlib.use(...) before pyplot import, or the MPLBACKEND environment variable. if a script “hangs in ssh”, it is waiting on a gui event loop β€” force Agg.

saving figures

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import os

x = np.linspace(0, 2 * np.pi, 200)
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(x, np.sin(x))
ax.set(xlabel="x", ylabel=r"$\sin x$", title="same figure, five files")

for name, kw in [("100dpi.png", {"dpi": 100}),
                 ("300dpi.png", {"dpi": 300}),
                 ("tight.png",  {"dpi": 100, "bbox_inches": "tight"}),
                 ("vector.pdf", {}),
                 ("vector.svg", {})]:
    path = "/tmp/mpl-" + name
    fig.savefig(path, **kw)
    print(f"{name:>12}: {os.path.getsize(path):7d} bytes")

print("styles available:", len(plt.style.available),
      "e.g.", plt.style.available[:3])
print("rcParams entries:", len(plt.rcParams))
  100dpi.png:   23785 bytes
  300dpi.png:   88269 bytes
   tight.png:   23457 bytes
  vector.pdf:   13625 bytes
  vector.svg:   24729 bytes
styles available: 29 e.g. ['Solarize_Light2', '_classic_test_patch', '_mpl-gallery']
rcParams entries: 322

the numbers teach the policy:

  • dpi only matters for raster: tripling it roughly \(3.7\times\)-ed the png. print wants 300; screens are fine at 100–150.
  • bbox_inches="tight" crops the saved file to the artists’ actual extent β€” the fix for amputated axis labels.
  • vector formats (pdf, svg) are resolution-independent and here smaller than the raster β€” for line art they are strictly better. use pdf for LaTeX documents, svg for the web; reserve png for images and plots with millions of points (where vector files balloon).
  • the pgf backend (fig.savefig("plot.pgf")) emits pgf/tikz commands that LaTeX compiles itself, so figure fonts and math are typeset by the document’s fonts β€” the same aesthetic this wiki gets by drawing its diagrams in tikz directly. for matplotlib-generated plots destined for a typeset pdf, pgf is the highest-fidelity route.

see also

  • numpy β€” every ax.plot argument is one of its arrays
  • pandas β€” df.plot() drives this machinery with labels attached
  • mastery of matplotlib β€” exercises