Debugging DPI Mismatch in Headless Matplotlib Exports

A headless matplotlib map export lands at the wrong DPI or pixel size for one of four reasons: a layout manager (constrained_layout or tight_layout) recomputed the canvas after you set figsize, the PNG pHYs chunk rounded a non-integer pixels-per-metre value, bbox_inches='tight' re-cropped the figure to its artists, or a CI runner substituted a fallback font whose wider glyph metrics changed the computed extent. The fix is deterministic in every case: pin the backend to Agg, disable layout managers, pass dpi explicitly to savefig with bbox_inches=None, and verify the pHYs/XResolution tag against the requested DPI after the file is written.

Core Diagnosis: Symptom to Root Cause to Fix

The trap in headless rendering is that matplotlib has two DPI values — the one you pass to plt.figure(dpi=...) and the one you pass to savefig(dpi=...) — and several code paths that resize the canvas between construction and write. Diagnose by the observable symptom first, then apply the matching fix. The decision tree below walks the three failure signatures a batch exporter actually produces.

DPI mismatch decision tree A decision tree starting from the symptom of a headless matplotlib export. Three branches: wrong pixel size leads to bbox_inches tight or a layout manager, fixed by disabling both; wrong DPI tag leads to pHYs integer rounding or a figure/savefig DPI split, fixed by asserting on pixels-per-metre and passing dpi to savefig; text overflow leads to a CI font fallback, fixed by installing fonts and clearing the cache. Export DPI or size is wrong Wrong pixel size (width x height off) Wrong DPI tag (299.99 not 300) Text overflow (CI runner only) Cause: bbox_inches='tight' re-crops, or layout manager resized the canvas Cause: pHYs stores px/metre as int, or figure DPI differs from savefig DPI Cause: missing font falls back to DejaVu; glyph metrics shift Fix: bbox_inches=None, pad_inches=0, and layout=None Fix: assert on px/metre int; pass dpi to savefig, not only to figure() Fix: install fonts in image, clear font cache, pin font.family Precondition: pin backend to Agg

Symptom 1 — wrong pixel size. You set figsize=(10, 8) at dpi=300 and expect a 3000×2400 px file, but the export is 2887×2400 or some other odd shape. Root cause: bbox_inches='tight' re-derives the bounding box from the rendered artists and crops to them, so the width/height are whatever the content occupies, not figsize * dpi. constrained_layout=True and tight_layout() do the same thing at a different stage — they resize axes to fit labels, which can shrink the drawable region. Fix: for deterministic dimensions, disable every layout manager and pass bbox_inches=None, pad_inches=0.

Symptom 2 — wrong DPI tag. The pixels are right but a downstream tool (a RIP, InDesign, or your own assertion) reports 299.9994 DPI instead of 300. Root cause: the PNG pHYs chunk stores resolution as pixels per metre in an unsigned integer. 300 DPI is 300 / 0.0254 = 11811.023... px/m, which rounds to 11811, and reading it back gives 11811 * 0.0254 ≈ 299.9994 DPI. This is lossless-to-tolerance rounding, not corruption. A related cause is passing dpi only to plt.figure() and letting savefig fall back to the rcParams["savefig.dpi"] default ("figure" in current versions, but overridable). Fix: assert on the integer px/metre value or a small DPI tolerance, and always pass dpi explicitly to savefig.

Symptom 3 — text overflow on CI only. The map renders perfectly on your workstation but labels spill outside the frame, or the pixel size drifts, only inside the CI container. Root cause: the runner image does not have your chosen font, so matplotlib substitutes DejaVu Sans (or another fallback) whose glyphs are wider; with bbox_inches='tight' those wider glyphs enlarge the computed box, and with a fixed box they overflow. Fix: install the exact font in the container, delete the stale font cache, and pin font.family so metrics are identical everywhere. Font-metric reproducibility is the same concern covered in Typography Rules for Maps.

Production-Ready Python Implementation

The function below exports a matplotlib map deterministically and then re-opens the file to verify that both the pixel dimensions and the embedded resolution tag match what was requested. It pins the backend, disables layout managers, and passes dpi at write time. Pillow reads back the pHYs/dpi metadata; see the Matplotlib savefig docs for the full parameter list.

# matplotlib==3.9.2, Pillow==10.4.0
import matplotlib
matplotlib.use("Agg")  # pin headless backend BEFORE importing pyplot

import matplotlib.pyplot as plt
from PIL import Image

# Reproducible metrics: pin the font so CI runners cannot substitute a fallback.
matplotlib.rcParams["font.family"] = "DejaVu Sans"
matplotlib.rcParams["svg.fonttype"] = "none"

INCH_PER_METRE = 1.0 / 0.0254  # 39.3700787... used by the PNG pHYs chunk


def export_map_fixed_dpi(gdf, out_path, figsize_in=(10.0, 8.0), dpi=300):
    """Render a GeoDataFrame to a fixed-DPI, fixed-pixel-size raster.

    Layout managers are disabled so the canvas you set is the canvas that
    renders, and dpi is passed to savefig so figure DPI cannot silently
    differ from write DPI.
    """
    # layout=None disables constrained_layout AND tight_layout entirely.
    fig, ax = plt.subplots(figsize=figsize_in, dpi=dpi, layout=None)
    gdf.plot(ax=ax, linewidth=0.4, edgecolor="black", facecolor="none")
    ax.set_axis_off()
    # Do NOT call fig.tight_layout() or set constrained_layout=True here.

    fig.savefig(
        out_path,
        dpi=dpi,             # explicit write DPI, never inherit the default
        bbox_inches=None,    # 'tight' would re-crop and change pixel size
        pad_inches=0,
        facecolor="white",
    )
    plt.close(fig)
    return out_path


def verify_dpi(out_path, figsize_in=(10.0, 8.0), dpi=300, dpi_tol=0.01):
    """Assert the written file matches the requested DPI and pixel size.

    The PNG pHYs chunk stores pixels-per-metre as an int, so 300 DPI reads
    back as ~299.9994. Compare on the integer px/metre to avoid false alarms.
    """
    expected_w = round(figsize_in[0] * dpi)
    expected_h = round(figsize_in[1] * dpi)
    expected_ppm = round(dpi * INCH_PER_METRE)  # 11811 for 300 DPI

    with Image.open(out_path) as im:
        width_px, height_px = im.size
        # Pillow exposes DPI as (x, y) inches; convert to px/metre for the
        # rounding-safe comparison, and also keep the raw DPI for reporting.
        x_dpi, y_dpi = im.info.get("dpi", (None, None))

    assert (width_px, height_px) == (expected_w, expected_h), (
        f"pixel size {width_px}x{height_px} != expected "
        f"{expected_w}x{expected_h} (layout manager or bbox='tight'?)"
    )
    if x_dpi is not None:
        read_ppm = round(x_dpi * INCH_PER_METRE)
        assert abs(read_ppm - expected_ppm) <= 1, (
            f"resolution {read_ppm} px/m != expected {expected_ppm} px/m"
        )
        assert abs(x_dpi - dpi) <= dpi_tol or abs(read_ppm - expected_ppm) <= 1
    return {"size_px": (width_px, height_px), "dpi": (x_dpi, y_dpi)}


# ---------------------------------------------------------------------------
# Example usage
# ---------------------------------------------------------------------------
# import geopandas as gpd
# gdf = gpd.read_file("districts.gpkg").to_crs("EPSG:3857")
# path = export_map_fixed_dpi(gdf, "sheet_01.png", figsize_in=(10, 8), dpi=300)
# print(verify_dpi(path, figsize_in=(10, 8), dpi=300))
# -> {'size_px': (3000, 2400), 'dpi': (299.99..., 299.99...)}

For PDF and SVG output the pixel-size symptom disappears (both are vector), but the resolution tag still matters: PDF stores a MediaBox in points where the physical size is figsize_in * 72, and savefig(..., dpi=...) controls only the rasterization DPI of embedded images and the effective DPI of any imshow basemap. Verify a PDF with pikepdf by checking the MediaBox equals figsize_in * 72 (a 10×8 inch sheet is [0, 0, 720, 576]). The vector-specific export concerns are covered further under DPI and Resolution Management.

Performance Tuning and Cartographic Best Practices

  • Pin Agg via MPLBACKEND=Agg in the container environment, not just in code. Setting the env var means every worker process is headless from the first import, avoiding a race where an interactive backend with a different device DPI is probed before matplotlib.use() runs. This matters when rendering is fanned out across processes, as in Batch Queue Orchestration.
  • Assert on pixels-per-metre, never on the raw DPI float. A test that requires dpi == 300.0 will flake on every valid PNG because of pHYs integer rounding. Compare round(read_dpi / 0.0254) against the expected px/metre integer with a tolerance of 1.
  • Bake fonts and clear the font cache in the image build. After apt-get install fonts-dejavu fonts-open-sans, delete ~/.cache/matplotlib so the next run rebuilds fontlist.json and actually sees the installed faces. A stale cache is the most common reason a freshly installed font is still ignored.
  • Separate figure size from crop. If you need both an exact pixel size and tight margins, set figsize_in to the final size and remove whitespace by tuning subplots_adjust/set_position, not by bbox_inches='tight'. Tight cropping trades reproducible dimensions for content-dependent ones.
  • Keep DPI and target scale consistent across the sheet set. A DPI that renders line weights correctly at one map scale can thin them illegibly at another; reconcile the two using the scale-to-DPI reasoning in Scale Mapping for Web and Print.

Integration and Next Steps

Fold verify_dpi into the export step rather than a separate test so a mismatch fails the job at the point it is produced, with the offending sheet named in the assertion. In a batch pipeline the verification cost is negligible — one file re-open per sheet — and it converts silent DPI drift into a hard, actionable error. This same explicit-dpi, disabled-layout, verify-after-write pattern is what the QGIS-based Batch Exporting 300 DPI PDFs from QGIS with Python workflow applies through the QGIS layout API, so a mixed matplotlib-and-QGIS render farm can share one resolution contract. When you parallelize the export across workers, keep the verification inside each task so a font-cache miss on a single runner cannot poison the whole sheet set unnoticed — a natural fit for the fan-out patterns in Batch Queue Orchestration.


Frequently Asked Questions

Why does my PNG report 299.9994 DPI instead of exactly 300?

The PNG pHYs chunk stores resolution as pixels per metre in an unsigned integer. 300 DPI is 300 / 0.0254 = 11811.023... px/m, which matplotlib writes as 11811, and readers convert back as 11811 * 0.0254 ≈ 299.9994 DPI. This is expected integer rounding, not a rendering fault. Assert on the px/metre integer (round(dpi / 0.0254)) or allow a DPI tolerance of about 0.01, rather than requiring exact float equality.

Why is my exported image a different pixel size than figsize * dpi?

bbox_inches='tight' recomputes the bounding box from the rendered artists and crops to them, so width/height no longer equal figsize * dpi. constrained_layout and tight_layout() similarly resize axes and can shrink the figure. For deterministic dimensions, build the figure with layout=None and call savefig(..., bbox_inches=None, pad_inches=0); then width_px = round(figwidth_in * dpi) holds exactly.

Why do labels overflow or shift only on the CI runner?

The CI image lacks the font your workstation has, so matplotlib falls back to DejaVu Sans with different glyph metrics. With bbox_inches='tight', the wider fallback glyphs enlarge the computed box and change the pixel size; with a fixed box they overflow the axes. Install the exact font in the container, delete ~/.cache/matplotlib to force a font-list rebuild, and pin font.family for reproducible metrics.

Does plt.figure(dpi=...) alone control the export resolution?

No. The figure DPI affects on-screen/interactive sizing and the default if savefig inherits it, but savefig has its own dpi argument (and an rcParams["savefig.dpi"] default). If the two differ, the written file uses the savefig value. Always pass dpi explicitly to savefig so the construction DPI and write DPI cannot diverge.


Back to DPI and Resolution Management