Pinning Matplotlib to the Agg Backend: A Headless Rendering Design
Pin matplotlib to the Agg backend so chart generation works on CI runners and display-less containers, with verification steps, memory limits, and the cases where Agg is the wrong tool.
01 Oct 2026, 16:18 UTC

A plotting script that works on a laptop often fails on a CI runner or in a slim container: import matplotlib.pyplot auto-selects a GUI backend, tries to reach an X server that isn't there, and the job dies with a display error — or quietly hangs. The useful takeaway: treat the backend as a deployment requirement, not an accident. Pin matplotlib to Agg, its software rasterizer, and verify the pin in the environment that actually runs the job.
Agg (Anti-Grain Geometry) renders figures into an in-memory pixel buffer and writes PNG files. It needs no display server, no Qt, no Tk. Everything below assumes matplotlib 3.x; the MPLBACKEND environment variable it relies on has been supported since the 2.x series, but re-check the details against your pinned version.
What the design has to guarantee
- Chart generation on machines with no display, including containers with no X11 or Wayland libraries installed.
- Immunity to import order — some third-party dependency will import pyplot before your code runs.
- Predictable memory, because a shared runner dies quietly when one job allocates a giant buffer.
- File output only: no windows, no event loop, no interaction.
The smallest design that meets them
Agg is the smallest such design: it ships with matplotlib, needs no extra system packages, and produces exactly the artifact most pipelines want — a PNG. The remaining decision is where to pin it. There are three supported places, and they differ in blast radius:
| Method | Scope | Choose it when |
|---|---|---|
MPLBACKEND=Agg environment variable | Every matplotlib process in that environment | CI, containers, schedulers. No code changes, and it holds even if a dependency imports pyplot first. |
matplotlib.use("Agg") at the top of the entry point | That process only | You own the application's first import. Keep the call above any pyplot import. |
backend: agg in a matplotlibrc file | Anyone whose config resolves to that file | Repo-wide script defaults. Avoid shipping this in a reusable library — it surprises consumers. |
# app.py — the first lines of the process
import matplotlib
matplotlib.use("Agg") # must execute before pyplot is imported
import matplotlib.pyplot as plt
For CI and containers, prefer the environment variable, set where the job is defined — for example ENV MPLBACKEND=Agg in a Dockerfile or the equivalent in your CI job's environment block. Backend names are case-insensitive, so Agg and agg both resolve.
For long-running services, skip pyplot
pyplot keeps global figure state, and matplotlib's documentation treats the pyplot interface as not thread-safe. A service rendering one chart per request should use the object-oriented API with an explicit Agg canvas instead:
from matplotlib.figure import Figure
from matplotlib.backends.backend_agg import FigureCanvasAgg
def render_png(x, y, out_path):
fig = Figure(figsize=(6, 4), dpi=100)
FigureCanvasAgg(fig) # attach the Agg canvas to the figure
ax = fig.subplots()
ax.plot(x, y)
fig.savefig(out_path) # writes a 600x400 PNG
Each call builds an independent figure with no global state to reset between requests. With pyplot you would need plt.close(fig) after every save to release the buffer.
Keeping untrusted input out of the renderer
The backend itself has a small surface: it reads your in-memory arrays, bundled font files, and the output path you hand it. No network, no plugins. The boundaries that matter are the parameters you let others influence:
- Figure geometry. The pixel buffer is width × height × 4 bytes. A user-supplied request for
figsize=(50, 40)atdpi=200produces a 10,000 × 8,000-pixel buffer — roughly 320 MB before savefig makes working copies. Clamp dpi and figsize to service-defined maximums. - Output paths. Never pass a request parameter straight to
savefig; derive the filename server-side. - Label strings. matplotlib interprets text between dollar signs as mathtext. If labels can come from users, either escape
$or document that labels are developer-controlled.
Checks that prove the pin took effect
Run these in the same image and virtualenv the real job uses — a check on your laptop proves nothing about the runner. First, confirm the resolved backend:
python -c "import matplotlib; print(matplotlib.get_backend())"
Expect a name that resolves to agg (the exact casing has varied across releases). Second, an end-to-end smoke test:
python -c "import matplotlib; matplotlib.use('Agg'); import matplotlib.pyplot as plt; fig, ax = plt.subplots(); ax.plot([1, 2], [3, 4]); fig.savefig('smoke.png')" && ls -l smoke.png
Expected: exit code 0 and a listing showing a non-empty smoke.png. The command writes into the current directory, so run it in a scratch location if that matters; no permissions are needed beyond write access there.
For CI, fail fast with an assertion in the test suite rather than discovering the failure at deploy time:
import matplotlib
def test_backend_is_agg():
assert matplotlib.get_backend().lower() == "agg"
Diagnostic shortcut: if a job fails with an error mentioning a display, Qt, or Tk — or hangs at plt.show() — check whether DISPLAY is set in the job environment. SSH X-forwarding and some base images export it even when no server is reachable. Unset it, or pin MPLBACKEND; pinning is the sturdier fix because auto-detection depends on environment variables you don't control.
Failure modes you should expect
- pyplot imported first. If a dependency imports pyplot before your
use()call, the backend may already be resolved. Recent 3.x releases attempt a switch whenuse()is called late, but treat that as a recovery path — the environment variable is the design. - show() does nothing. Agg has no event loop, so
plt.show()displays nothing; recent releases warn that the canvas is non-interactive. Scripts must end insavefig. - Callbacks never fire.
mpl_connecthandlers for mouse and keyboard events are dead code under Agg. - PNG only. Agg is a raster pipeline. If the deliverable is a PDF or SVG, use matplotlib's
pdforsvgbackends instead — they are also headless and pin the same way. - Memory scales with pixels. Rasterization happens on the CPU, so very large figures cost time and RAM in proportion to pixel count. There is no GPU escape hatch among matplotlib's standard backends.
What would change this design
- Vector deliverables (print-quality reports): switch the pin to the
pdforsvgbackend; the rest of the design carries over. - Interactive exploration: a GUI backend on a developer workstation is correct, so scope the pin to server environments — set it in CI configuration, not inside a shared library.
- Concurrent rendering at scale: keep one Figure per worker via the object-oriented API, or move to a process pool if you need hard isolation from pyplot's global state.
- Plots embedded in a desktop application: the target becomes a Qt-backed design with an offscreen platform on headless Linux — a different spike with its own trade-offs.
The design is cheap: one environment variable, one import-order rule, one assertion. Its proof is not a successful run on your machine but get_backend() resolving to agg in the runner that matters.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.