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.
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.
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.
Related
- Generating PDF Reports Dynamically — the report-building workflow this extends
- Add Page Numbers and Headers to PDF Reports — chrome around the charts
- Fix ReportLab Unicode Font Errors — font substitution in generated documents
- Generating Reports from Pipeline Data — feeding real data into the charts
- Writing Excel Formulas and Charts with openpyxl — the spreadsheet equivalent
Part of Generating PDF Reports Dynamically.