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.

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

LibraryApproachBest ForCSS Support
ReportLabProgrammaticData-heavy reports, pixel controlNo
WeasyPrintHTML → PDFTemplated docs, styled reportsFull CSS3
fpdf2ProgrammaticSimple docs, no dependenciesNo

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 @page for headers, footers, and page breaks.
How do I add digital signatures to PDFs?
Use the pyhanko library 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.Pool to 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).