Build HTML Reports with Jinja2 Templates
A nightly job processes four thousand documents and writes a log nobody reads. The obvious upgrade is an HTML summary, and the first attempt fails at the wrong moment:
html = f"<h1>Run {run_id}</h1><table>"
for row in rows:
html += f"<tr><td>{row['file']}</td><td>{row['status']}</td></tr>"
One filename contains &, another <, and the table stops rendering halfway down. Adding a chart means writing an image file next to the HTML, so the report is now two files that must travel together, and emailing it produces a page of broken image icons.
Root Cause
Two problems, both from building HTML by hand. String concatenation does no escaping, so any &, < or > in the data becomes markup — a filename like Q3<final>.pdf closes the cell and opens an unknown tag, and everything after it renders unpredictably. And an HTML file that references external images, stylesheets or fonts is not a document: it is a page that only works in the directory where it was built. A report that will be emailed, archived or attached to a ticket has to be one file. Jinja2 fixes the first with autoescaping, and data URIs fix the second by embedding every asset in the file itself.
Minimal Diagnostic
Check a produced report for the two things that make it fragile: unescaped data and external references.
# pip install beautifulsoup4
import re
from pathlib import Path
from bs4 import BeautifulSoup
EXTERNAL = re.compile(r"^(https?:)?//|^(?!data:)[^:]*\.(png|jpg|jpeg|svg|css|js|woff2?)$", re.IGNORECASE)
def audit_report(path: Path) -> None:
html = path.read_text(encoding="utf-8")
soup = BeautifulSoup(html, "html.parser")
external = []
for tag, attribute in (("img", "src"), ("link", "href"), ("script", "src")):
for element in soup.find_all(tag):
value = element.get(attribute)
if value and EXTERNAL.match(value):
external.append(f"{tag}[{attribute}]={value[:60]}")
print(f"{path.name}: {len(html) / 1024:.0f}KB, {len(external)} external reference(s)")
for reference in external[:8]:
print(f" EXTERNAL {reference}")
unclosed = len(re.findall(r"<t[dr]\b", html)) - len(re.findall(r"</t[dr]>", html))
if unclosed:
print(f" MALFORMED: {unclosed} unclosed table element(s) — likely unescaped data")
print(f" images: {len(soup.find_all('img'))}, "
f"data URIs: {sum(1 for i in soup.find_all('img') if (i.get('src') or '').startswith('data:'))}")
if __name__ == "__main__":
audit_report(Path("out/report.html"))
report.html: 38KB, 3 external reference(s)
EXTERNAL link[href]=style.css
EXTERNAL img[src]=chart-throughput.png
EXTERNAL img[src]=logo.png
MALFORMED: 2 unclosed table element(s) — likely unescaped data
images: 2, data URIs: 0
Three files the report depends on and two broken table cells — everything the concatenated version gets wrong, in one output.
Fix: A Jinja Environment with Autoescaping and Embedded Assets
Render from a template, escape by default, and inline everything the page needs.
# pip install jinja2
import base64
import mimetypes
from datetime import datetime, timezone
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
TEMPLATES = Path("templates")
def data_uri(path: Path) -> str:
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:{mime};base64,{encoded}" # changed: asset travels in the file
def build_environment() -> Environment:
env = Environment(
loader=FileSystemLoader(TEMPLATES),
autoescape=select_autoescape(["html", "xml"]), # changed: escaping on by default
trim_blocks=True,
lstrip_blocks=True,
undefined=__import__("jinja2").StrictUndefined, # changed: a typo fails, not blanks
)
env.filters["data_uri"] = lambda p: data_uri(Path(p))
env.filters["thousands"] = lambda v: f"{v:,}" if isinstance(v, (int, float)) else v
env.filters["duration"] = lambda s: f"{int(s // 60)}m {int(s % 60)}s" if s >= 60 else f"{s:.1f}s"
env.filters["pct"] = lambda v, total: f"{(v / total * 100):.1f}%" if total else "—"
return env
def render_report(context: dict, out: Path, template_name: str = "report.html.j2") -> Path:
env = build_environment()
context = {**context,
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
"stylesheet": (TEMPLATES / "report.css").read_text(encoding="utf-8")}
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(env.get_template(template_name).render(context), encoding="utf-8")
return out
StrictUndefined is the setting that turns a silent blank into a failure. Jinja's default renders an unknown variable as an empty string, so a renamed field produces a report with a missing figure and no indication that anything went wrong — which in a report that people make decisions from is worse than no report at all.
The template itself stays plain:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Run {{ run_id }} — document pipeline</title>
<style>{{ stylesheet }}</style>
</head>
<body>
<h1>Run {{ run_id }}</h1>
<p class="meta">Generated {{ generated_at }} · {{ total | thousands }} documents · {{ elapsed | duration }}</p>
<img class="chart" src="{{ chart_path | data_uri }}" alt="Documents processed per hour across the run">
<table>
<thead><tr><th>File</th><th>Pages</th><th>Status</th></tr></thead>
<tbody>
{% for row in rows %}
<tr class="{{ row.status }}">
<td>{{ row.file }}</td>
<td class="num">{{ row.pages }}</td>
<td>{{ row.status }}</td>
</tr>
{% else %}
<tr><td colspan="3">No documents processed.</td></tr>
{% endfor %}
</tbody>
</table>
</body>
</html>
{{ row.file }} escapes automatically, so Q3<final>.pdf renders as text rather than markup. The {% else %} on the for-loop handles the empty case, which is the state a report is most often in the first time it runs in production.
Variant Fix 1: Embedding Charts Without Temporary Files
A chart rendered by matplotlib can go straight into the template as a data URI, without touching disk:
# pip install matplotlib
import base64
import io
import matplotlib
matplotlib.use("Agg") # headless backend, before pyplot
import matplotlib.pyplot as plt
def chart_data_uri(hours: list[str], counts: list[int], alt_width: float = 7.5) -> str:
figure, axes = plt.subplots(figsize=(alt_width, 2.8), dpi=110)
axes.bar(hours, counts, color="#2563eb")
axes.set_ylabel("documents")
axes.spines[["top", "right"]].set_visible(False)
figure.tight_layout()
buffer = io.BytesIO()
figure.savefig(buffer, format="png", bbox_inches="tight")
plt.close(figure) # release the figure, or memory grows per report
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
Setting the Agg backend before importing pyplot is what keeps this working in a cron job or container with no display — the failure otherwise is described in matplotlib no display error. Closing each figure matters in a loop: matplotlib keeps every open figure alive, so a job producing two hundred reports exhausts memory without it.
Variant Fix 2: When the Report Must Be a PDF
The same HTML converts to PDF with no layout work if the template carries print rules:
@page { size: A4; margin: 18mm 15mm; }
@media print {
.chart { max-width: 100%; page-break-inside: avoid; }
table { page-break-inside: auto; }
tr { page-break-inside: avoid; page-break-after: auto; }
thead { display: table-header-group; } /* repeat the header on every page */
.no-print { display: none; }
}
# pip install weasyprint
from pathlib import Path
from weasyprint import HTML
def report_to_pdf(html_path: Path, pdf_path: Path) -> Path:
HTML(filename=str(html_path)).write_pdf(str(pdf_path))
return pdf_path
thead { display: table-header-group } is the line that makes a long table readable in PDF — without it the column headings appear once, on page one, and pages two onwards are unlabelled columns of numbers. Because every asset is already a data URI, WeasyPrint needs no base URL and no network access, which also means it cannot hang waiting for one.
Reusing One Layout Across Several Reports
A pipeline usually needs more than one report — a nightly summary, a per-customer extract, an exception list. Template inheritance keeps the styling, header and footer in one place:
{# templates/base.html.j2 #}
<!doctype html><html lang="en"><head>
<meta charset="utf-8"><title>{% block title %}Report{% endblock %}</title>
<style>{{ stylesheet }}</style></head>
<body>
<header><img src="{{ logo | data_uri }}" alt="Company logo" class="logo"></header>
{% block content %}{% endblock %}
<footer class="meta">Generated {{ generated_at }}{% block footnote %}{% endblock %}</footer>
</body></html>
A child template then declares only what differs, with {% extends "base.html.j2" %} at the top and its own content block. Changing the logo, the print margins or the footer afterwards is one edit rather than one per report. Keep the blocks few and coarse — a base template with fifteen override points is harder to reason about than three separate templates.
Keeping the Report Honest
A report is a claim about what happened, so the numbers in it should come from the same place the pipeline's own accounting does. Two habits keep that true.
Compute totals once, in the code, and pass them to the template rather than summing in Jinja. A template that adds up a column is a second implementation of the same arithmetic, and the two drift the moment a filter is added to one of them. Passing a prepared summary object also means the totals can be asserted in a test without rendering anything.
Always show what was excluded. A report listing four thousand successes and silently omitting the eleven files that failed to parse is worse than no report, because it creates confidence that is not warranted. A line reading 11 quarantined (see appendix) costs nothing and is the first thing an experienced reader looks for.
Verification
Render with adversarial data and assert the output is safe and self-contained.
# pip install jinja2 beautifulsoup4
from pathlib import Path
from bs4 import BeautifulSoup
def verify_report(path: Path, expected_rows: int) -> None:
html = path.read_text(encoding="utf-8")
soup = BeautifulSoup(html, "html.parser")
body_rows = soup.select("tbody tr")
assert len(body_rows) == expected_rows, f"{len(body_rows)} row(s), expected {expected_rows}"
for image in soup.find_all("img"):
source = image.get("src", "")
assert source.startswith("data:"), f"external image reference: {source[:60]}"
assert image.get("alt"), "image without alt text"
assert not soup.find_all("link", rel="stylesheet"), "external stylesheet linked"
assert not soup.find_all("script", src=True), "external script referenced"
assert "{{" not in html and "{%" not in html, "unrendered Jinja tag in the output"
print(f"{path.name}: {len(body_rows)} rows, {len(soup.find_all('img'))} embedded image(s), "
f"{len(html) / 1024:.0f}KB, self-contained")
if __name__ == "__main__":
hostile = [{"file": 'Q3<final>&"draft".pdf', "pages": 12, "status": "processed"},
{"file": "<script>alert(1)</script>.pdf", "pages": 1, "status": "failed"}]
render_report({"run_id": "test", "rows": hostile, "total": 2, "elapsed": 3.2,
"chart_path": "img/chart.png"}, Path("out/test.html"))
verify_report(Path("out/test.html"), expected_rows=2)
assert "<script>alert(1)</script>.pdf" not in Path("out/test.html").read_text()
Rendering with a filename containing a script tag is the test worth keeping. It passes trivially with autoescaping on, and fails the moment someone adds |safe to a field to fix a rendering quirk — which is how escaping usually gets disabled.
FAQ
When is |safe appropriate?
Only for HTML the code generated itself, such as a pre-rendered chart SVG. Never for anything derived from filenames, document text or user input.
Why is my report several megabytes? Embedded PNG charts at high DPI. Drop to 110 DPI, or use SVG, which is usually smaller and stays sharp.
Can I use one template for HTML and PDF? Yes — that is the point of the print stylesheet. Keep the screen and print rules in the same file.
How do I include a table of a thousand rows? Summarise in the report and attach the full data as CSV. A thousand-row HTML table is not read by anyone.
Related
- Generating Reports from Pipeline Data — the reporting workflow end to end
- Fix matplotlib No Display Error in Headless Jobs — charts in a cron job or container
- Generating PDF Reports Dynamically — the PDF side in depth
- Scheduling and Logging Automation Jobs — where the report data comes from