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 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!) anddpiβ 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
LocatororFormatter, never manual strings. - data artists: what you drew.
- text: title, axis labels, annotations β all
Textartists, 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:
| mark | when |
|---|---|
plot | \(y\) against ordered \(x\) β trends, trajectories, functions |
scatter | paired observations, no ordering implied; size/colour as extra channels |
bar | a handful of categories (if the x-axis is numeric and dense, you wanted a line) |
hist | one variable’s distribution (bin count is a real modelling choice) |
imshow | matrices and images β anything indexed by two integers |
contour / contourf | smooth 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 withplt.rc_context({...}).- style sheets β named
rcParamsbundles:plt.style.use("ggplot"), 29 shipped (measured below), stackable, and you can ship your own.mplstylefile per project β the correct way to make every figure in a thesis agree.
3d and animation, briefly
- 3d:
fig.add_subplot(projection="3d")gives anAxes3Dwithplot_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 yourupdate(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.plotargument is one of its arrays - pandas β
df.plot()drives this machinery with labels attached - mastery of matplotlib β exercises
Backlinks (4)
1. Wiki /wiki/
Knowledge is a paradox. The more one understand, the more one realises the vastness of his ignorance.
2. Numpy /wiki/ccs/programming/libraries/numpy/
numpy is one idea executed ruthlessly: a typed, contiguous block of memory plus enough metadata to reinterpret it without copying. everything else β broadcasting, views, vectorised speed β falls out of that design. learn the memory model and the api stops being a list of functions and becomes a handful of consequences. π all timings and outputs on this page are real (python 3, numpy 2.4, apple m-series).
3. Pandas Library /wiki/ccs/programming/libraries/pandas/
pandas is numpy with labels. a Series is a 1-d array married to an index; a DataFrame is a dict of such columns sharing one row index. the single organising idea β the one that explains both the magic and the bugs β is that every operation aligns on labels first and computes second. everything on this page runs against pandas 3.0 (outputs are real).
π