Python PDF Generation: ReportLab and WeasyPrint
Python offers two main approaches for generating PDFs: ReportLab for pixel-perfect programmatic control (great for invoices and data-heavy reports), and WeasyPrint/fpdf2 for HTML-to-PDF conversion (great for templated documents). This guide covers both tools — building invoices with ReportLab, generating reports from HTML templates with WeasyPrint, and serving PDFs as FastAPI streaming responses.
Table of Contents
ReportLab: Building PDFs Programmatically
pip install reportlab
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4, letter
from reportlab.lib.units import inch, cm
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image
from reportlab.lib.enums import TA_CENTER, TA_RIGHT
# Canvas API — low-level drawing
def create_simple_pdf(filename: str):
c = canvas.Canvas(filename, pagesize=A4)
width, height = A4
# Draw text at absolute position (x, y from bottom-left)
c.setFont("Helvetica-Bold", 24)
c.setFillColor(colors.HexColor("#1e3a5f"))
c.drawString(inch, height - inch, "Annual Report 2026")
# Draw a line
c.setStrokeColor(colors.lightblue)
c.setLineWidth(2)
c.line(inch, height - 1.1 * inch, width - inch, height - 1.1 * inch)
# Regular text
c.setFont("Helvetica", 11)
c.setFillColor(colors.black)
text = c.beginText(inch, height - 1.5 * inch)
text.setFont("Helvetica", 11)
text.setLeading(15)
for line in ["Techoral, Inc.", "Mysore, Karnataka, India", "info@techoral.com"]:
text.textLine(line)
c.drawText(text)
# Draw rectangle
c.setFillColor(colors.HexColor("#f0f8ff"))
c.setStrokeColor(colors.HexColor("#1e3a5f"))
c.roundRect(inch, 3 * inch, width - 2 * inch, 1 * inch, 10, fill=1)
c.showPage() # end page
c.save()
print(f"Created {filename}")
Tables and Data Reports
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import inch
from reportlab.lib.styles import getSampleStyleSheet
def create_data_report(filename: str, data: list[dict]):
doc = SimpleDocTemplate(filename, pagesize=A4,
leftMargin=inch, rightMargin=inch,
topMargin=inch, bottomMargin=inch)
styles = getSampleStyleSheet()
story = []
# Title
story.append(Paragraph("Sales Report Q2 2026", styles["Title"]))
story.append(Spacer(1, 0.2 * inch))
# Table data
headers = list(data[0].keys())
table_data = [headers] + [[str(row[k]) for k in headers] for row in data]
table = Table(table_data, repeatRows=1)
table.setStyle(TableStyle([
# Header row
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e3a5f")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 10),
("ALIGN", (0, 0), (-1, 0), "CENTER"),
# Data rows
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("FONTSIZE", (0, 1), (-1, -1), 9),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f5f5f5")]),
("ALIGN", (1, 1), (-1, -1), "RIGHT"), # right-align numbers
# Borders
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 8),
]))
story.append(table)
doc.build(story)
# Example usage
sales_data = [
{"Product": "Widget A", "Units": "1,250", "Revenue": "$62,500", "Growth": "+15%"},
{"Product": "Widget B", "Units": "890", "Revenue": "$44,500", "Growth": "+8%"},
{"Product": "Service X", "Units": "340", "Revenue": "$102,000", "Growth": "+32%"},
]
create_data_report("sales_report.pdf", sales_data)
Complete Invoice Example
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, HRFlowable
from reportlab.lib import colors
from reportlab.lib.styles import ParagraphStyle
from io import BytesIO
def generate_invoice(invoice_data: dict) -> bytes:
"""Generate invoice PDF and return bytes."""
buffer = BytesIO()
doc = SimpleDocTemplate(buffer, pagesize=A4,
leftMargin=1.5*cm, rightMargin=1.5*cm,
topMargin=1.5*cm, bottomMargin=1.5*cm)
styles = getSampleStyleSheet()
story = []
# Header: company vs client
header = Table([
[
Paragraph(f"{invoice_data['company_name']}
{invoice_data['company_address']}", styles["Normal"]),
Paragraph(f"INVOICE #{invoice_data['invoice_number']}
Date: {invoice_data['date']}
Due: {invoice_data['due_date']}", styles["Normal"]),
]
], colWidths=["60%", "40%"])
story.append(header)
story.append(HRFlowable(width="100%", thickness=1, color=colors.lightgrey))
story.append(Spacer(1, 0.3 * inch))
# Bill To
story.append(Paragraph("Bill To:", styles["Normal"]))
story.append(Paragraph(f"{invoice_data['client_name']}
{invoice_data['client_address']}", styles["Normal"]))
story.append(Spacer(1, 0.3 * inch))
# Line items
items_data = [["Description", "Qty", "Unit Price", "Total"]]
subtotal = 0
for item in invoice_data["items"]:
total = item["qty"] * item["price"]
subtotal += total
items_data.append([item["name"], str(item["qty"]),
f"${item['price']:.2f}", f"${total:.2f}"])
tax = subtotal * invoice_data.get("tax_rate", 0.18)
grand_total = subtotal + tax
items_data += [
["", "", "Subtotal:", f"${subtotal:.2f}"],
["", "", f"Tax ({invoice_data.get('tax_rate', 0.18)*100:.0f}%):", f"${tax:.2f}"],
["", "", "Total Due:", f"${grand_total:.2f}"],
]
items_table = Table(items_data, colWidths=["50%", "10%", "20%", "20%"])
items_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2c3e50")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("ALIGN", (1, 0), (-1, -1), "RIGHT"),
("FONTNAME", (-2, -1), (-1, -1), "Helvetica-Bold"),
("GRID", (0, 0), (-1, len(invoice_data["items"])), 0.5, colors.lightgrey),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]))
story.append(items_table)
doc.build(story)
return buffer.getvalue()
WeasyPrint: HTML to PDF
pip install weasyprint jinja2
from weasyprint import HTML, CSS
from jinja2 import Environment, FileSystemLoader
import os
env = Environment(loader=FileSystemLoader("templates/"))
def render_pdf_from_template(template_name: str, context: dict) -> bytes:
"""Render a Jinja2 HTML template and convert to PDF."""
template = env.get_template(template_name)
html_content = template.render(**context)
return HTML(string=html_content).write_pdf()
<!-- templates/invoice.html -->
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Helvetica', sans-serif; font-size: 12px; }
.header { display: flex; justify-content: space-between; margin-bottom: 30px; }
.invoice-title { font-size: 28px; font-weight: bold; color: #2c3e50; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th { background: #2c3e50; color: white; padding: 10px; text-align: left; }
td { padding: 8px; border-bottom: 1px solid #eee; }
.total-row { font-weight: bold; background: #f5f5f5; }
@page { size: A4; margin: 2cm; }
</style>
</head>
<body>
<div class="header">
<div>
<div class="invoice-title">INVOICE</div>
<div>{{ company_name }}</div>
</div>
<div><b>#{{ invoice_number }}</b><br>Date: {{ date }}</div>
</div>
<table>
<thead><tr><th>Description</th><th>Qty</th><th>Price</th><th>Total</th></tr></thead>
<tbody>
{% for item in items %}
<tr><td>{{ item.name }}</td><td>{{ item.qty }}</td><td>${{ item.price }}</td><td>${{ item.qty * item.price }}</td></tr>
{% endfor %}
<tr class="total-row"><td colspan="3">Total</td><td>${{ total }}</td></tr>
</tbody>
</table>
</body>
</html>
fpdf2: Lightweight Alternative
from fpdf import FPDF
class InvoicePDF(FPDF):
def header(self):
self.set_font("Helvetica", "B", 16)
self.set_text_color(44, 62, 80)
self.cell(0, 10, "TECHORAL INC.", align="L")
self.ln(8)
def footer(self):
self.set_y(-15)
self.set_font("Helvetica", "I", 8)
self.set_text_color(128)
self.cell(0, 10, f"Page {self.page_no()}", align="C")
def create_fpdf_invoice(items: list[dict]) -> bytes:
pdf = InvoicePDF()
pdf.add_page()
pdf.set_auto_page_break(auto=True, margin=15)
pdf.set_font("Helvetica", "B", 12)
pdf.cell(120, 8, "Description", border=1, fill=True)
pdf.cell(30, 8, "Qty", border=1, fill=True, align="C")
pdf.cell(40, 8, "Total", border=1, fill=True, align="R")
pdf.ln()
pdf.set_font("Helvetica", "", 11)
for item in items:
pdf.cell(120, 8, item["name"], border=1)
pdf.cell(30, 8, str(item["qty"]), border=1, align="C")
pdf.cell(40, 8, f"${item['qty']*item['price']:.2f}", border=1, align="R")
pdf.ln()
return bytes(pdf.output())
FastAPI PDF Endpoints
from fastapi import FastAPI
from fastapi.responses import Response, StreamingResponse
import io
app = FastAPI()
@app.get("/invoices/{invoice_id}/pdf")
async def download_invoice(invoice_id: int):
data = await get_invoice_data(invoice_id)
pdf_bytes = generate_invoice(data)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename=invoice-{invoice_id}.pdf"},
)
@app.get("/reports/sales/pdf")
async def stream_sales_report():
"""Stream a large PDF without loading it all into memory."""
async def generate():
# Build PDF in chunks
buffer = BytesIO()
create_data_report(buffer, await get_sales_data())
buffer.seek(0)
while chunk := buffer.read(8192):
yield chunk
return StreamingResponse(
generate(),
media_type="application/pdf",
headers={"Content-Disposition": "attachment; filename=sales-report.pdf"},
)
Library Comparison
| Library | Approach | Best For | CSS Support |
|---|---|---|---|
| ReportLab | Programmatic | Data-heavy reports, pixel control | No |
| WeasyPrint | HTML → PDF | Templated docs, styled reports | Full CSS3 |
| fpdf2 | Programmatic | Simple docs, no dependencies | No |
Frequently Asked Questions
- Can WeasyPrint render charts and images?
- Yes. Embed Base64-encoded images in your HTML (
<img src="data:image/png;base64,...">) or reference local file paths. For charts, render a Matplotlib figure to PNG bytes and embed it. WeasyPrint handles CSS@pagefor headers, footers, and page breaks. - How do I add digital signatures to PDFs?
- Use the
pyhankolibrary for PDF signing with X.509 certificates. ReportLab and WeasyPrint don't support digital signatures natively. For simple integrity verification, add an HMAC of the content to the PDF metadata. - What is the fastest way to generate many PDFs concurrently?
- PDF generation is CPU-bound. Use
multiprocessing.Poolto generate PDFs across CPU cores. Each worker generates one PDF; the main process collects results. Avoid asyncio for this — it won't help with CPU-bound work (see our threading vs asyncio guide).