Create Dynamic Invoice PDFs Automatically
Per-customer invoice generation means 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 manually. This guide builds that pipeline with both ReportLab (canvas control, good for custom fonts and symbols) and Jinja2+WeasyPrint (HTML/CSS layout, easier to style).
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
Always open with encoding='utf-8'. Parse the JSON items column before handing it to any renderer.
# 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: CSS handles layout, @page rules inject page numbers and a persistent header.
# 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")
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.