Server-Side Chart Generation with Matplotlib: Agg Backend and Figure Lifecycle
Use Agg before importing pyplot, close figures explicitly, and declare fonts to generate charts reliably in headless containers and long-running services.
13 Sept 2025, 13:50 UTC

The problem
A long-running Python service that renders charts on demand can fail in two common ways. It may abort with a display or backend error when started in a headless container, CI job, or web worker that has no X11 server. It may also consume more memory with every request until the process is restarted, because Matplotlib's pyplot state machine keeps references to every figure it creates.
The useful takeaway is to select the Agg non-interactive backend before importing pyplot, build charts with the object-oriented Figure and Axes API, and close every figure after it is serialized. This avoids GUI dependencies and prevents figure accumulation in long-running processes.
Requirements
- Render PNG charts from a Python service with no graphical display.
- Run inside containers or CI environments where X11, Wayland, and GUI toolkits are absent.
- Generate many charts over the lifetime of a single process without unbounded memory growth.
- Keep font rendering predictable across environments.
Smallest suitable design
Use Matplotlib's Agg backend. Agg is a raster backend that writes PNG output and does not require a display server. Select it with matplotlib.use("Agg") before any matplotlib.pyplot import. After the backend is fixed, prefer the object-oriented API: create a Figure, add one or more Axes, draw the data, save to a buffer, and close the figure.
Example rendering module
Run this code inside the application process, not in a GUI session. No special filesystem permissions are required beyond read access to fonts and write access to the output buffer or destination path.
import io
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
def render_line_chart(x_values, y_values, title="Chart"):
"""Return PNG bytes for a simple line chart.
The figure is created explicitly and closed before the function returns,
so the pyplot state machine does not retain it.
"""
fig, ax = plt.subplots()
try:
ax.plot(x_values, y_values)
ax.set_title(title)
ax.set_xlabel("x")
ax.set_ylabel("y")
buffer = io.BytesIO()
fig.savefig(buffer, format="png", dpi=100, bbox_inches="tight")
buffer.seek(0)
return buffer.read()
finally:
plt.close(fig)
Expected check: the returned bytes should begin with the PNG signature b"\x89PNG\r\n\x1a\n". A caller can verify this with:
png_bytes = render_line_chart([1, 2, 3], [1, 4, 9])
assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n"
Risk: calling matplotlib.use() after pyplot has already been imported elsewhere in the same process is unsupported and can raise a backend error or silently keep the previous backend. Import matplotlib and set the backend at the top of the module before importing pyplot.
Trust and data boundaries
Fonts
Text rendering depends on fonts installed on the host. Matplotlib ships with DejaVu fonts by default, but container base images often omit additional font packages. If a chart requests a font that is missing, Matplotlib falls back to a default or raises a rendering error, and identical code can produce different output across environments.
Practical check: declare required font packages in the container image, for example fonts-dejavu-core on Debian-based images, and confirm with:
fc-list | grep -i dejavu
Input data
Treat chart data as untrusted if it comes from an API request or user upload. Extremely large arrays can consume memory during rendering, and malformed labels can produce unexpected text layout. Validate array lengths, numeric types, and label length before passing values to Axes methods.
Concurrency
The pyplot state machine is not thread-safe. Do not share a single Figure or Axes across threads, and do not call pyplot functions from multiple request handlers simultaneously. Create a new figure per request and close it before returning the response.
Operational checks
- Verify the backend after import:
import matplotlib
print(matplotlib.get_backend())
Expected output: Agg. Any GUI backend such as Qt5Agg or TkAgg indicates the wrong module was imported first or the backend was changed too late.
- Run without a display:
unset DISPLAY
unset WAYLAND_DISPLAY
python render_module.py
Expected result: the process exits without a TclError, RuntimeError, or display-related abort.
- Check memory growth across repeated renders:
for _ in range(1000):
render_line_chart(range(100), range(100))
Monitor process RSS (resident set size, the portion of memory held in RAM) before and after the loop. If figures are closed, RSS should remain approximately flat. If plt.close(fig) is omitted, RSS typically grows with each iteration.
Failure modes
| Failure | Cause | Detection |
|---|---|---|
| Backend or display error at import | A GUI backend was selected, or matplotlib.use() was called after pyplot import. | Check matplotlib.get_backend(); inspect import tracebacks for TclError or display errors. |
| Memory growth over time | Figures remain referenced by the pyplot state machine because they were not closed. | Track process RSS during a rendering loop; compare with and without plt.close(fig). |
| Missing glyphs or fallback fonts | Requested font is not installed in the container or host. | Compare rendered output against a reference image; list installed fonts with fc-list. |
| Corrupt or truncated PNG | Buffer was not seeked to zero before reading, or figure was closed before savefig completed. | Validate the first eight bytes against the PNG signature; attempt to open the bytes with an image decoder. |
Conditions that would change the design
- Vector output is required. Agg produces raster PNG. For scalable SVG, PDF, or PostScript output, switch to the corresponding backend, but confirm optional dependencies are installed and test font embedding and transparency behavior, which differs from Agg.
- Interactive preview is needed. If developers need to open chart windows locally, use a GUI backend only in development, never in the headless service path.
- High-concurrency rendering. If many requests arrive simultaneously, consider rendering in separate processes or a task queue rather than threads, because Matplotlib's global state and some font caches are not safe for concurrent shared use.
- Custom fonts or brand styling. Bundle font files with the application and set
matplotlib.rcParams["font.family"]explicitly, then verify rendering in the target container.
Limitations
Agg output is rasterized, so charts do not scale without resampling at a higher DPI. The object-oriented API reduces state-machine leaks, but any code that still calls plt.figure() or plt.gca() can reintroduce retained figures. This design assumes Matplotlib is already installed; backend selection does not install optional GUI or vector dependencies.
Verification step: after deploying a change, import the rendering module in a container without DISPLAY set, generate one chart, and confirm the output starts with the PNG signature and that process memory does not increase after ten sequential renders with figures closed.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.