Create Dynamic Invoice PDFs Automatically
The symptom that brings people here is always the same: one invoice was hand-built in a word processor, and now there are two hundred customers who each need the same layout with different numbers. Per-customer invoice generation solves that by turning one data row into a complete, formatted PDF — line items, subtotals, tax, a due date, and a footer with payment terms — without touching a template by hand. This guide builds that pipeline two ways: with ReportLab for canvas-level coordinate control (best for logos, custom fonts, and currency symbols), and with Jinja2 driving WeasyPrint for HTML/CSS layout that a non-Python colleague can restyle.
This is one of the concrete output patterns described in Generating PDF Reports Dynamically. If you encounter garbled boxes or a UnicodeEncodeError for currency symbols like € when using ReportLab, see Fix ReportLab Unicode Font Errors.
Root cause of common invoice failures
Two failures account for most broken invoice scripts:
- Table overflow / silent row truncation. WeasyPrint calculates page breaks synchronously. Without
page-break-inside: avoidon<tr>, the renderer may split a row across pages and silently discard the overflow. The symptom is a PDF that ends mid-table with no error raised. - Unicode encoding crash before render. When a CSV is opened without
encoding='utf-8', Python raisesUnicodeDecodeErroron the first non-ASCII character (accented names,€,£). This happens in the data layer, before HTML or PDF rendering even starts.
Prerequisites
pip install reportlab weasyprint jinja2 pandas
Create a sample invoice CSV:
python - <<'EOF'
import csv
rows = [
{"invoice_id":"INV-001","customer":"Acme Corp","email":"[email protected]",
"due_date":"2026-07-18","tax_rate":0.20,
"items":'[{"desc":"Cloud setup","qty":10,"rate":150},{"desc":"API integration","qty":25,"rate":120}]'},
{"invoice_id":"INV-002","customer":"Béta SARL","email":"[email protected]",
"due_date":"2026-07-25","tax_rate":0.19,
"items":'[{"desc":"Consulting","qty":8,"rate":200},{"desc":"Support retainer","qty":1,"rate":500}]'},
]
with open("invoices.csv","w",newline="",encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=rows[0].keys()); w.writeheader(); w.writerows(rows)
EOF
Step 1 — Load and parse invoice data
Load the sheet with pandas and always open with encoding='utf-8'. Parse the JSON items column and pre-compute every total in this one place, so both renderers stay dumb — they only format numbers that already exist on the record.
# pip install pandas
import json
from pathlib import Path
import pandas as pd
def load_invoices(path: Path) -> list[dict]:
try:
df = pd.read_csv(path, encoding="utf-8") # explicit encoding prevents UnicodeDecodeError
df.columns = df.columns.str.strip().str.lower()
records = df.to_dict("records")
for rec in records:
raw = rec.get("items", "[]")
rec["items"] = json.loads(raw) if isinstance(raw, str) else raw
for item in rec["items"]:
item["total"] = round(item["qty"] * item["rate"], 2)
subtotal = sum(i["total"] for i in rec["items"])
rec["subtotal"] = subtotal
rec["tax_amount"] = round(subtotal * float(rec["tax_rate"]), 2)
rec["grand_total"] = round(subtotal + rec["tax_amount"], 2)
return records
except FileNotFoundError:
raise SystemExit(f"Invoice file not found: {path}")
except json.JSONDecodeError as exc:
raise SystemExit(f"Malformed items JSON: {exc}")
invoices = load_invoices(Path("invoices.csv"))
Minimal reproducible diagnostic
Before building the full pipeline, confirm parsing is correct:
# pip install pandas
import json, pprint
from pathlib import Path
import pandas as pd
df = pd.read_csv(Path("invoices.csv"), encoding="utf-8")
first = df.iloc[0].to_dict()
items = json.loads(first["items"])
print(f"Customer: {first['customer']}")
print(f"Line items: {len(items)}")
pprint.pprint(items)
Expected output shows the customer name (including accented characters) and all line items with no decode error.
Step 2 — Generate with Jinja2 + WeasyPrint
Good default choice: WeasyPrint lets CSS handle layout, and @page rules inject page numbers and a persistent header for free. The one rule that prevents silent data loss is page-break-inside: avoid on <tr> — without it, a long line-item table can drop rows at a page boundary with no error.
# pip install weasyprint jinja2
from pathlib import Path
from jinja2 import Environment, BaseLoader
from weasyprint import HTML
INVOICE_TEMPLATE = """<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>
@page {
size: A4;
margin: 18mm 15mm 22mm;
@top-left { content: "INVOICE"; font-size: 8pt; color: #475569; }
@top-right { content: "{{ inv.invoice_id }}"; font-size: 8pt; color: #475569; }
@bottom-center { content: "Page " counter(page) " of " counter(pages); font-size: 7pt; color: #475569; }
}
body { font-family: sans-serif; font-size: 10pt; color: #0f172a; margin: 0; }
h1 { font-size: 22pt; margin-bottom: 2mm; }
.meta { font-size: 9pt; color: #475569; margin-bottom: 8mm; }
table { width: 100%; border-collapse: collapse; margin-top: 6mm; }
thead tr { background: #2563eb; color: #fff; }
th, td { padding: 5px 8px; font-size: 9pt; }
th { text-align: left; }
.right { text-align: right; }
tbody tr { page-break-inside: avoid; } /* prevents row splitting */
tbody tr:nth-child(even) { background: #f6f8fb; }
.totals { margin-top: 6mm; text-align: right; font-size: 10pt; }
.totals td { padding: 2px 8px; }
.grand { font-weight: bold; font-size: 12pt; color: #2563eb; }
.footer { margin-top: 12mm; font-size: 8pt; color: #475569;
border-top: 1px solid #e2e8f0; padding-top: 4mm; }
</style></head>
<body>
<h1>Invoice</h1>
<div class="meta">
<strong>{{ inv.customer }}</strong><br>
Invoice: {{ inv.invoice_id }} | Due: {{ inv.due_date }}<br>
Contact: {{ inv.email }}
</div>
<table>
<thead><tr>
<th>Description</th>
<th class="right">Qty</th>
<th class="right">Rate</th>
<th class="right">Total</th>
</tr></thead>
<tbody>
{% for item in inv.items %}
<tr>
<td>{{ item.desc }}</td>
<td class="right">{{ item.qty }}</td>
<td class="right">${{ "%.2f"|format(item.rate) }}</td>
<td class="right">${{ "%.2f"|format(item.total) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<table class="totals">
<tr><td>Subtotal</td><td>${{ "%.2f"|format(inv.subtotal) }}</td></tr>
<tr><td>Tax ({{ (inv.tax_rate * 100)|int }}%)</td><td>${{ "%.2f"|format(inv.tax_amount) }}</td></tr>
<tr class="grand"><td>Total Due</td><td>${{ "%.2f"|format(inv.grand_total) }}</td></tr>
</table>
<div class="footer">Payment terms: 30 days. Bank transfer preferred.<br>
Thank you for your business.</div>
</body></html>"""
def render_invoice_weasyprint(inv: dict, out_dir: Path) -> Path:
out_dir.mkdir(parents=True, exist_ok=True)
out = out_dir / f"{inv['invoice_id']}.pdf"
env = Environment(loader=BaseLoader())
tmpl = env.from_string(INVOICE_TEMPLATE)
html_str = tmpl.render(inv=inv)
try:
HTML(string=html_str).write_pdf(str(out))
return out
except Exception as exc:
raise RuntimeError(f"WeasyPrint failed for {inv['invoice_id']}: {exc}") from exc
for inv in invoices:
path = render_invoice_weasyprint(inv, Path("invoices"))
print(f"Generated: {path}")
Step 3 — Generate with ReportLab
Use ReportLab when you need exact coordinate placement, a company logo, or non-Latin fonts. The canvas approach gives explicit control over every element.
# pip install reportlab
from pathlib import Path
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib import colors
from reportlab.platypus import (
SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, HRFlowable
)
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_RIGHT, TA_CENTER
BLUE = colors.HexColor("#2563eb")
MUTED = colors.HexColor("#475569")
BORDER = colors.HexColor("#e2e8f0")
SOFT = colors.HexColor("#f6f8fb")
def render_invoice_reportlab(inv: dict, out_dir: Path) -> Path:
out_dir.mkdir(parents=True, exist_ok=True)
out = out_dir / f"{inv['invoice_id']}_rl.pdf"
styles = getSampleStyleSheet()
right_s = ParagraphStyle("right_s", parent=styles["Normal"], alignment=TA_RIGHT)
doc = SimpleDocTemplate(
str(out), pagesize=A4,
leftMargin=15*mm, rightMargin=15*mm,
topMargin=20*mm, bottomMargin=20*mm,
)
def _header_footer(canvas, doc):
canvas.saveState()
canvas.setFont("Helvetica", 7)
canvas.setFillColor(MUTED)
canvas.drawString(15*mm, A4[1]-12*mm, "INVOICE")
canvas.drawRightString(A4[0]-15*mm, A4[1]-12*mm, inv["invoice_id"])
canvas.drawCentredString(A4[0]/2, 10*mm, f"Page {doc.page}")
canvas.restoreState()
# Line-item table
header_row = [["Description", "Qty", "Rate", "Total"]]
item_rows = [
[item["desc"], str(item["qty"]),
f"${item['rate']:.2f}", f"${item['total']:.2f}"]
for item in inv["items"]
]
tbl = Table(
header_row + item_rows,
colWidths=[95*mm, 20*mm, 30*mm, 30*mm],
repeatRows=1,
)
tbl.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), BLUE),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("ALIGN", (1, 0), (-1, -1), "RIGHT"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, SOFT]),
("GRID", (0, 0), (-1, -1), 0.5, BORDER),
]))
# Totals block as a narrow right-aligned table
totals_data = [
["Subtotal", f"${inv['subtotal']:.2f}"],
[f"Tax ({int(inv['tax_rate']*100)}%)", f"${inv['tax_amount']:.2f}"],
["Total Due", f"${inv['grand_total']:.2f}"],
]
totals_tbl = Table(totals_data, colWidths=[40*mm, 35*mm], hAlign="RIGHT")
totals_tbl.setStyle(TableStyle([
("FONTSIZE", (0, 0), (-1, -1), 9),
("ALIGN", (1, 0), (-1, -1), "RIGHT"),
("FONTNAME", (0, 2), (-1, 2), "Helvetica-Bold"),
("TEXTCOLOR", (0, 2), (-1, 2), BLUE),
("LINEABOVE", (0, 2), (-1, 2), 0.5, BORDER),
]))
story = [
Paragraph("Invoice", styles["h1"]),
Spacer(1, 2*mm),
Paragraph(f"<b>{inv['customer']}</b>", styles["Normal"]),
Paragraph(f"{inv['invoice_id']} · Due {inv['due_date']}", styles["Normal"]),
Paragraph(inv["email"], styles["Normal"]),
Spacer(1, 6*mm),
tbl,
Spacer(1, 4*mm),
totals_tbl,
Spacer(1, 8*mm),
HRFlowable(width="100%", thickness=0.5, color=BORDER),
Spacer(1, 2*mm),
Paragraph("Payment terms: 30 days. Bank transfer preferred.", styles["Normal"]),
]
try:
doc.build(story, onFirstPage=_header_footer, onLaterPages=_header_footer)
return out
except Exception as exc:
raise RuntimeError(f"ReportLab failed for {inv['invoice_id']}: {exc}") from exc
for inv in invoices:
path = render_invoice_reportlab(inv, Path("invoices"))
print(f"Generated: {path}")
Variant fixes
Accented names and currency symbols crash ReportLab
The default Helvetica core font is Type 1 and lacks many Unicode glyphs. Register a TrueType font before building the document:
# pip install reportlab
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from pathlib import Path
# Download: https://github.com/dejavu-fonts/dejavu-fonts/releases
font_path = Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf")
pdfmetrics.registerFont(TTFont("DejaVuSans", str(font_path))) # register once at module level
# Then use "DejaVuSans" wherever you previously used "Helvetica"
See Fix ReportLab Unicode Font Errors for a full walkthrough including CID fonts and encoding='utf-8' on drawString.
Batch generation with error isolation
Isolate per-invoice failures so one bad record does not abort the run:
# pip install reportlab weasyprint jinja2 pandas
from pathlib import Path
results = {"ok": [], "failed": []}
for inv in invoices:
try:
path = render_invoice_weasyprint(inv, Path("invoices"))
results["ok"].append(inv["invoice_id"])
except Exception as exc:
results["failed"].append({"id": inv["invoice_id"], "error": str(exc)})
print(f"Generated {len(results['ok'])}, failed {len(results['failed'])}")
for failure in results["failed"]:
print(f" FAILED {failure['id']}: {failure['error']}")
Once per-customer PDFs are generated, assemble them into a single batch delivery file using Merging and Splitting PDF Documents.
Read invoice data from Excel instead of CSV
Data from pandas-based Excel pipelines works identically — swap read_csv for read_excel:
# pip install pandas openpyxl
import pandas as pd
from pathlib import Path
df = pd.read_excel(Path("invoices.xlsx"), engine="openpyxl")
df.columns = df.columns.str.strip().str.lower()
Verification
# pip install pypdf
from pathlib import Path
from pypdf import PdfReader
def verify_invoice_pdf(path: Path, expected_customer: str) -> None:
reader = PdfReader(str(path))
assert len(reader.pages) >= 1, f"{path.name}: no pages"
text = " ".join(p.extract_text() or "" for p in reader.pages)
assert expected_customer in text, (
f"{path.name}: customer name '{expected_customer}' not found in PDF text"
)
print(f"OK: {path.name}")
verify_invoice_pdf(Path("invoices/INV-001.pdf"), "Acme Corp")
verify_invoice_pdf(Path("invoices/INV-002.pdf"), "Béta SARL")
FAQ
Should I use WeasyPrint or ReportLab for invoices?
Default to Jinja2 + WeasyPrint: the template is HTML/CSS, so a colleague can restyle the invoice without touching Python, and @page rules give headers, footers, and page numbering for free. Switch to ReportLab only when you need exact coordinate placement — a logo pinned to millimetre offsets, a pre-printed background, or non-Latin fonts that WeasyPrint's font stack cannot resolve.
Why does my invoice PDF end mid-table with rows missing?
WeasyPrint calculates page breaks synchronously and will split a table row across a page boundary unless you tell it not to. Add tbody tr { page-break-inside: avoid; } (already present in the template above). Without it there is no error — the overflow rows are simply discarded, which is why the PDF looks like it "stops early".
How do I add a company logo to the invoice?
In WeasyPrint, embed it as a base64 data URI (<img src="data:image/png;base64,...">) so there is no external file lookup at render time. In ReportLab, use Image(str(logo_path), width=40*mm, height=15*mm) as the first flowable in the story, or draw it in the _header_footer callback with canvas.drawImage for a fixed position on every page.
How do I format currency with thousands separators?
The templates use "%.2f" for two decimals. For grouped output like 1,249.00, format in Python before rendering — f"{value:,.2f}" — and store the formatted string on the record, or register a Jinja2 filter. Keep the raw numeric value for the totals math and only format at the display edge.
Why does the euro sign or an accented customer name render as an empty box in ReportLab?
ReportLab's built-in Helvetica is a Type 1 core font limited to Latin-1, so €, ™, and characters like Béta fall outside its glyph table. Register a TrueType font with pdfmetrics.registerFont(TTFont(...)) before building the document — the full walkthrough is in Fix ReportLab Unicode Font Errors.
How do I deliver all invoices as one combined PDF? Generate the per-customer files as shown here, then concatenate them with Merging and Splitting PDF Documents. Keeping generation and assembly separate means a single bad record fails only its own invoice, not the whole batch.
Related
- Generating PDF Reports Dynamically — parent guide covering WeasyPrint, ReportLab, charts, and pagination in depth
- Fix ReportLab Unicode Font Errors — fix garbled boxes or
UnicodeEncodeErrorfor€,™, accented characters - Merging and Splitting PDF Documents — combine individual invoice PDFs into one batch delivery file
- Automating Excel Report Generation — same data sources can drive Excel-format invoices in parallel
Part of Generating PDF Reports Dynamically.