Embed Charts in PDF Reports with Matplotlib

A generated report with a table of numbers and no chart gets skimmed. Adding one is straightforward and the naive route fails in three ways at once:

import matplotlib.pyplot as plt
plt.plot(x, y)
plt.savefig("chart.png")          # blurry at print size
story.append(Image("chart.png"))  # overflows the frame; aspect ratio ignored

On a headless server it may not even get that far:

UserWarning: FigureCanvasAgg is non-interactive, and thus cannot be shown
RuntimeError: main thread is not in main loop

All three come from the same place: Matplotlib is a screen-first library with a stateful global interface, and a PDF report needs the opposite — an explicit figure, a fixed physical size, and no display.

Root Cause

Matplotlib chooses an interactive backend by default. On a server with no display that backend either warns and produces nothing useful, or crashes when a GUI toolkit is initialised off the main thread. The fix is to select the Agg backend before importing pyplot.

Resolution is the second issue. A figure saved at the default 100 DPI and then scaled up to fill a 16 cm column in the PDF is being enlarged, so the text in the chart is interpolated and looks soft next to the crisp vector type around it. Either raise the DPI to match the print size, or skip rasterisation entirely and embed the chart as vector drawing commands.

The third is memory. plt.plot accumulates figures in a global registry; a report loop that creates 300 charts without closing them holds all 300 in memory and eventually exhausts it.

Raster against vector for an embedded chart A Matplotlib figure can reach the PDF two ways. The raster route saves a PNG at print resolution, typically two hundred DPI, and inserts it as a ReportLab Image flowable, which is simple and produces a larger file whose text does not scale. The vector route saves SVG and converts it to a ReportLab Drawing, which keeps text selectable and sharp at any zoom but depends on the converter supporting every feature used. Figure object Agg backend savefig PNG dpi = 200 into a BytesIO buffer Image flowable simple, always works larger file, fixed resolution savefig SVG svglib to Drawing one more dependency Drawing flowable sharp at any zoom text stays selectable Start with raster at 200 DPI; move to vector when the chart must survive zooming or printing at A3

Prerequisites

pip install "reportlab>=4,<5" "matplotlib>=3.8,<4" "pandas>=2.2,<3"

# Optional, for the vector route
pip install svglib

The Fix: Headless Backend, Explicit Figure, Print DPI

# pip install "matplotlib>=3.8,<4" "reportlab>=4,<5" "pandas>=2.2,<3"
import matplotlib
matplotlib.use("Agg")          # BEFORE importing pyplot — selects the headless backend

from io import BytesIO
from pathlib import Path

import matplotlib.pyplot as plt
import pandas as pd
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import Image, Paragraph, SimpleDocTemplate, Spacer

OUT = Path("out/report_with_chart.pdf")

def bar_chart(frame: pd.DataFrame, x: str, y: str, title: str,
              width_cm: float = 16.0, height_cm: float = 8.0, dpi: int = 200) -> Image:
    """Render a bar chart and return it as a correctly sized ReportLab flowable."""
    if frame.empty:
        raise ValueError("Refusing to chart an empty frame")

    # Figure size in INCHES; 1 cm = 0.3937 in. Sizing here, not in the PDF, keeps text legible.
    figure, axes = plt.subplots(figsize=(width_cm / 2.54, height_cm / 2.54), dpi=dpi)
    try:
        axes.bar(frame[x].astype(str), frame[y], color="#2563eb")
        axes.set_title(title, fontsize=11)
        axes.set_ylabel(y.replace("_", " ").title(), fontsize=9)
        axes.tick_params(labelsize=8)
        axes.spines[["top", "right"]].set_visible(False)
        axes.grid(axis="y", linewidth=0.5, alpha=0.3)
        figure.tight_layout()

        buffer = BytesIO()
        figure.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight")
        buffer.seek(0)
    finally:
        plt.close(figure)          # always close: the global registry leaks otherwise

    return Image(buffer, width=width_cm * cm, height=height_cm * cm)

def build_report(frame: pd.DataFrame, dest: Path) -> Path:
    styles = getSampleStyleSheet()
    story = [
        Paragraph("Regional performance", styles["Heading1"]),
        Paragraph("Units shipped by region for the quarter.", styles["BodyText"]),
        Spacer(1, 0.5 * cm),
        bar_chart(frame, x="region", y="units", title="Units by region"),
        Spacer(1, 0.5 * cm),
    ]
    dest.parent.mkdir(parents=True, exist_ok=True)
    SimpleDocTemplate(str(dest), pagesize=A4,
                      topMargin=2 * cm, bottomMargin=2 * cm).build(story)
    return dest

if __name__ == "__main__":
    df = pd.DataFrame({"region": ["EMEA", "AMER", "APAC"], "units": [1204, 980, 655]})
    print(build_report(df, OUT))

Four decisions in that function matter. matplotlib.use("Agg") runs before the pyplot import, which is the only ordering that works. The figure is sized in the physical units it will occupy on the page, so a 9 pt axis label is 9 pt in the PDF rather than being scaled to something else. dpi=200 is the sweet spot for print-quality raster without inflating the file. And plt.close(figure) in a finally block is what keeps a 300-chart report from exhausting memory.

Passing a BytesIO buffer rather than a temporary file avoids both the cleanup and the race between two concurrent report runs writing chart.png.

Variant: Vector Charts That Stay Sharp

For a chart that will be zoomed, printed at A3, or read on a high-resolution display, embed vector drawing commands instead of pixels.

# pip install svglib "matplotlib>=3.8,<4" "reportlab>=4,<5"
from io import BytesIO

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from reportlab.lib.units import cm
from svglib.svglib import svg2rlg

def vector_chart(frame, x: str, y: str, title: str, width_cm: float = 16.0):
    """Render the chart as SVG and convert it to a ReportLab Drawing."""
    figure, axes = plt.subplots(figsize=(width_cm / 2.54, 8 / 2.54))
    try:
        axes.plot(frame[x], frame[y], color="#2563eb", linewidth=2)
        axes.set_title(title, fontsize=11)
        figure.tight_layout()
        buffer = BytesIO()
        figure.savefig(buffer, format="svg", bbox_inches="tight")
        buffer.seek(0)
    finally:
        plt.close(figure)

    drawing = svg2rlg(buffer)
    # svg2rlg returns the drawing at SVG user units; scale it to the target width
    scale = (width_cm * cm) / drawing.width
    drawing.width *= scale
    drawing.height *= scale
    drawing.scale(scale, scale)
    return drawing

The scaling block is required: svg2rlg preserves the SVG's own coordinate system, so without it the drawing lands at whatever size Matplotlib chose and overlaps the margins. Vector output also keeps the chart's text selectable in the PDF, which matters when the report is indexed or read by assistive technology.

The trade-off is fidelity. svglib supports the common Matplotlib output well, but hatching, some transparency effects and embedded fonts can render differently — check one real chart before committing a whole report to the vector route.

Variant: One Chart Per Section in a Loop

Report generators build many charts. Two habits keep the memory flat and the layout intact.

# pip install "matplotlib>=3.8,<4" "reportlab>=4,<5" "pandas>=2.2,<3"
from reportlab.platypus import KeepTogether, PageBreak, Paragraph, Spacer
from reportlab.lib.units import cm

def sectioned_report(groups: dict[str, pd.DataFrame], dest: Path, styles) -> Path:
    """One page per group, each with a heading, a chart and a note kept together."""
    story = []
    for index, (name, frame) in enumerate(sorted(groups.items())):
        block = [
            Paragraph(f"{name}", styles["Heading2"]),
            Spacer(1, 0.3 * cm),
            bar_chart(frame, "month", "units", f"{name} — monthly units", height_cm=7.0),
            Spacer(1, 0.2 * cm),
            Paragraph(f"{len(frame)} month(s) of data.", styles["BodyText"]),
        ]
        story.append(KeepTogether(block))       # never split a heading from its chart
        if index < len(groups) - 1:
            story.append(PageBreak())
    SimpleDocTemplate(str(dest), pagesize=A4).build(story)
    return dest

KeepTogether is the small detail that separates a generated report from a professional one: without it, a page break can land between the heading and the chart it introduces. Combine it with the running headers from add page numbers and headers to PDF reports for a document that reads as though it were laid out by hand.

Variant: Fonts and Consistency With the Report

A chart whose labels use Matplotlib's default DejaVu Sans inside a report set in Helvetica looks like a screenshot pasted in. Match them.

# pip install "matplotlib>=3.8,<4"
import matplotlib

matplotlib.rcParams.update({
    "font.family": "sans-serif",
    "font.sans-serif": ["Helvetica", "Arial", "DejaVu Sans"],   # first available wins
    "axes.edgecolor": "#cbd5e1",
    "axes.labelcolor": "#475569",
    "text.color": "#0f172a",
    "xtick.color": "#475569",
    "ytick.color": "#475569",
    "axes.titlesize": 11,
    "figure.facecolor": "white",
})

Setting rcParams once at import time applies to every chart in the run, which is far more maintainable than styling each figure. If a named font is missing, Matplotlib falls back silently and logs nothing — the same class of silent substitution described in fix ReportLab Unicode font errors, so verify the rendered output rather than trusting the configuration.

Size the figure once, in physical units The correct chain sets the figure size in inches to match the target width on the page, saves at two hundred DPI, and places the image at exactly that width, so nothing is scaled. The incorrect chain uses the default figure size and DPI and then stretches the image to fill the column, which enlarges every pixel and makes the chart text look soft against the surrounding vector type. Where blurriness comes from figsize = 16cm / 2.54 the target width, in inches savefig(dpi=200) 1260 px for 16 cm Image(width=16*cm) placed 1:1, sharp default figsize 6.4 x 4.8 in savefig(dpi=100) 640 px wide stretched to 16 cm 2x enlargement, soft Decide the physical size first; DPI then only controls how many pixels fill it What happens when figures are never closed Two lines across a loop that builds two hundred charts. With plt.close called after each figure, resident memory stays flat at around one hundred and forty megabytes. Without it, memory climbs steadily past one and a half gigabytes because every figure remains registered in the global pyplot state until the process exits. Resident memory across 200 generated charts 0 1.6 GB with plt.close() without plt.close() chart 1 chart 200 pyplot keeps every figure alive until it is closed explicitly

Verification

A chart that failed to render produces a PDF that still builds. Assert on the artefact.

# pip install "pypdf>=4.2,<6"
from pathlib import Path
from pypdf import PdfReader

def assert_charts_embedded(pdf_path: Path, expected_images: int = 1,
                           min_kb: int = 20) -> None:
    """Fail when the expected number of images is missing or suspiciously small."""
    assert pdf_path.exists(), f"{pdf_path} was not produced"
    reader = PdfReader(str(pdf_path))

    images = []
    for page in reader.pages:
        resources = page.get("/Resources", {})
        xobjects = resources.get("/XObject")
        if xobjects is None:
            continue
        for name, ref in xobjects.get_object().items():
            obj = ref.get_object()
            if obj.get("/Subtype") == "/Image":
                images.append((name, len(obj.get_data() if hasattr(obj, "get_data") else b"")))

    assert len(images) >= expected_images, (
        f"{len(images)} embedded image(s), expected at least {expected_images}"
    )
    tiny = [name for name, size in images if size < min_kb * 1024]
    assert not tiny, f"image(s) below {min_kb} KB — chart may be blank: {tiny}"
    print(f"{len(images)} chart image(s) verified in {pdf_path.name}")

if __name__ == "__main__":
    assert_charts_embedded(Path("out/report_with_chart.pdf"), expected_images=1)

The size floor catches the failure that matters: a chart built from an empty frame renders as axes with no data, which is a valid image of a blank plot and passes any count-based check. For a vector-route report, count Form XObjects instead, or assert that the chart title string appears in the extracted text — a proof that only works because vector output keeps its text.

FAQ

Do I have to call matplotlib.use("Agg")? On a headless server, yes, and it must come before import matplotlib.pyplot. Setting the MPLBACKEND=Agg environment variable achieves the same thing and is convenient in a container or a scheduled job.

Why is my chart text tiny in the PDF? The figure was created small and then placed large, so everything in it shrank relative to the page. Size the figure to the width it will occupy and leave the placement at 1:1.

Raster or vector? Raster at 200 DPI for most reports: simpler, no extra dependency, and indistinguishable at normal reading size. Vector when the chart will be zoomed or printed large, or when the text must remain selectable.

How do I stop memory growing across hundreds of charts? Close every figure with plt.close(figure), and prefer the object-oriented interface (plt.subplots) over the stateful plt.plot, which keeps figures in a global registry until closed explicitly.

Can I use the same figures in an Excel report? Yes — save the PNG and insert it with openpyxl, or use its native charts so the data stays live. The native-chart route is covered in writing Excel formulas and charts with openpyxl.

Part of Generating PDF Reports Dynamically.