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.

Per-customer invoice generation pipeline A single CSV row carrying invoice_id, customer, an items JSON string and a tax_rate flows into load_invoices(), which parses the JSON and pre-computes subtotal, tax_amount and grand_total. The enriched record then branches to two render engines — Jinja2 with WeasyPrint for HTML and CSS layout, and ReportLab with Platypus flowables for exact coordinate control — and each engine writes a per-customer INV-xxx.pdf. One data row → totals computed once → two render engines CSV data row invoice_id · customer items JSON · tax_rate load_invoices() parse items JSON subtotal · tax_amount grand_total Jinja2 + WeasyPrint HTML/CSS · @page rules flowing tables, restyle without Python ReportLab Platypus flowables exact x/y grid, logos, custom fonts INV-xxx.pdf per customer INV-xxx.pdf per customer

Root cause of common invoice failures

Two failures account for most broken invoice scripts:

  1. Table overflow / silent row truncation. WeasyPrint calculates page breaks synchronously. Without page-break-inside: avoid on <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.
  2. Unicode encoding crash before render. When a CSV is opened without encoding='utf-8', Python raises UnicodeDecodeError on 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 }} &nbsp;|&nbsp; 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()
Which parts of an invoice come from where An invoice page split into four bands. The letterhead and footer come from the template and never change. The party block and totals come from a single record. The line item table comes from a repeating collection. Keeping these three sources separate is what lets a template change without touching the data code. letterhead — template party block — one record line items — a collection totals — derived three sources, kept separate so the template can change alone

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")
Reconcile the printed total against the lines The sum of the line item amounts plus VAT must equal the total printed on the invoice, to the penny. Computing both independently and asserting equality catches a rounding difference introduced by float arithmetic, which is the single most common defect in generated invoices and the hardest to notice by eye. Assert the arithmetic, do not eyeball it sum(line amounts) + VAT == the printed total, to the penny Float arithmetic is what breaks this — keep money in Decimal or integer pence

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.

Part of Generating PDF Reports Dynamically.