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:

  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

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 }} &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()

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")

Part of Generating PDF Reports Dynamically.