Generating PDFs in Python

· 13 min read · Updated March 20, 2026 · beginner
pdf reportlab fpdf2 weasyprint pdf-generation reporting python

Introduction

PDFs are a common output format for Python applications. Invoices, automated reports, generated certificates, signed documents — all of these start as PDFs. Python has several libraries that can build PDFs programmatically, and the right one depends on your use case.

The three most popular options are:

  • ReportLab — the oldest and most powerful. It gives you fine-grained control over every element in a PDF. Most Python PDF libraries are built on top of it.
  • fpdf2 — a simpler alternative inspired by a PHP library called FPDF. It has an easier learning curve for straightforward documents.
  • WeasyPrint — a different approach entirely. You write HTML and CSS, and WeasyPrint converts it to a PDF. This is useful if you already have web-based content or know CSS well.

This tutorial covers ReportLab, fpdf2, and WeasyPrint. By the end you will be able to create multi-page PDFs with text, shapes, images, and charts.

Setting Up

Install all three libraries with pip:

pip install reportlab fpdf2 weasyprint

ReportLab works on Python 3.6+. fpdf2 and WeasyPrint require Python 3.8 or newer.

WeasyPrint has one gotcha worth knowing upfront: it requires system-level dependencies (Pango, HarfBuzz, Fontconfig, cairo) that don’t install via pip alone on some systems. On Linux and macOS these are usually already available. On Windows, use the pre-built installer. If you run into issues, the WeasyPrint installation docs cover the details.

To verify ReportLab and fpdf2 installed correctly:

python -c "import reportlab; import fpdf2; print('Both libraries installed successfully')"

If you see the success message, you are ready to go.

Your First PDF with ReportLab

ReportLab uses a concept called a canvas — think of it as a blank sheet of paper that you can draw on using coordinates. The canvas starts empty, you add elements to it, and when you are done you call save() to write the PDF to disk.

Here is a minimal example that creates a single-page PDF with a heading and some text:

from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import letter

# Create a canvas that produces "hello.pdf"
# pagesize=letter sets the page to 8.5 x 11 inches
c = canvas.Canvas("hello.pdf", pagesize=letter)

# Get page dimensions so we can position things relative to the page
width, height = letter

# Select a bold 24-point Helvetica font
c.setFont("Helvetica-Bold", 24)

# drawCentredString draws text centered at the given x coordinate
# The y coordinate starts from the bottom of the page (unlike most GUI toolkits)
c.drawCentredString(width / 2, height - 2 * 72, "Hello, PDF!")

# Switch to a smaller regular font for the body text
c.setFont("Helvetica", 14)
c.drawCentredString(width / 2, height - 3 * 72, "Your first PDF generated in Python.")

# showPage marks the current page as complete
# save() finalizes the PDF and writes it to disk
c.showPage()
c.save()

Run the script with python hello.py and open hello.pdf. You should see a page with your heading and text centered near the top.

One thing that catches beginners is the coordinate system. In ReportLab, the origin (0, 0) sits at the bottom-left corner of the page. As the y value increases, you move upward. As the x value increases, you move right. Most graphics libraries use a top-left origin, so this takes a little getting used to.

Drawing Shapes

The canvas API gives you direct control over basic shapes:

from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import letter
from reportlab.lib import colors

c = canvas.Canvas("shapes.pdf", pagesize=letter)

# Draw a filled rectangle: x, y, width, height, stroke=1, fill=1
c.setFillColor(colors.HexColor("#4A90E2"))
c.rect(50, 600, 200, 100, stroke=1, fill=1)

# Draw a circle: center_x, center_y, radius
c.setFillColor(colors.HexColor("#E94B35"))
c.circle(400, 650, 50, stroke=1, fill=1)

# Draw a line: x1, y1, x2, y2
c.setStrokeColor(colors.black)
c.setLineWidth(2)
c.line(50, 580, 250, 580)

c.showPage()
c.save()

Colors accept hex strings, RGB tuples, or the color objects from reportlab.lib.colors.

Using Paragraphs Instead of Raw Coordinates

The canvas API is powerful but gets tedious when you are laying out a lot of text. ReportLab provides a higher-level API called Platypus that handles text wrapping, font styling, and spacing automatically.

The core concept in Platypus is a story — a list of elements (called flowables) that are assembled into a document in order. Here is how it works:

from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import inch

# SimpleDocTemplate takes care of page assembly
# You give it a filename and a page size, then call build() with your story
doc = SimpleDocTemplate("story.pdf", pagesize=A4)

# Get a collection of pre-defined styles (Normal, Heading1, Title, etc.)
styles = getSampleStyleSheet()

# Build the story: a list of flowables in the order they should appear
story = [
    Paragraph("Welcome to ReportLab", styles["Title"]),
    Spacer(1, 0.5 * inch),  # Adds a 0.5-inch vertical gap
    Paragraph(
        "This text wraps automatically within the page margins. "
        "Platypus handles the layout so you do not have to calculate positions manually.",
        styles["Normal"]
    ),
]

# build() assembles all flowables into a PDF
doc.build(story)

Paragraph wraps the text and respects the font, size, color, and alignment defined in the style. Spacer adds empty vertical space. You don’t need to calculate x and y coordinates for every line of text.

Drawing Tables

Tables are one of the most common things to put in a PDF report. ReportLab’s Table class handles this, and you use TableStyle to control the appearance:

from reportlab.platypus import SimpleDocTemplate, Table, TableStyle
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors

doc = SimpleDocTemplate("table.pdf", pagesize=A4)

# Table data: a list of rows, each row is a list of cell values
data = [
    ["Product", "Quantity", "Price"],
    ["Widget A", "10", "$5.00"],
    ["Widget B", "3", "$12.50"],
    ["Widget C", "7", "$8.00"],
]

# Create the table with column widths
table = Table(data, colWidths=[2.5 * inch, 1.5 * inch, 1.5 * inch])

# TableStyle controls the visual appearance
table.setStyle(TableStyle([
    # Header row: bold text, light blue background, bottom border
    ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
    ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#4A90E2")),
    ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
    ("GRID", (0, 0), (-1, -1), 0.5, colors.grey),  # Grid lines on all cells

    # Alternate row colors: light grey on even rows, white on odd
    ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.HexColor("#F5F5F5"), colors.white]),

    # Center-align all content
    ("ALIGN", (0, 0), (-1, -1), "CENTER"),
    ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),

    # Add padding inside cells
    ("TOPPADDING", (0, 0), (-1, -1), 6),
    ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]))

doc.build([table])

TableStyle is a list of commands that apply formatting rules. Each command is a tuple of (command_name, start_cell, end_cell, arguments). The start_cell and end_cell use column and row indices, where (0, 0) is the top-left cell and (-1, -1) means “the last cell.” This way you can format ranges of cells concisely.

Adding Images

To embed an image in a ReportLab canvas, use drawImage(). You provide the filename, x/y coordinates (from the bottom-left origin), and at least one dimension. ReportLab automatically scales the other dimension to preserve the aspect ratio:

from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import letter

c = canvas.Canvas("image.pdf", pagesize=letter)

# drawImage(filename, x, y, width, height)
# Omitting height causes it to scale automatically to match width
c.drawImage("photo.jpg", 100, 500, width=150)

c.showPage()
c.save()

Here is the same thing using Platypus, which is easier when you are mixing images with text:

from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import inch

doc = SimpleDocTemplate("image_doc.pdf", pagesize=A4)
styles = getSampleStyleSheet()

story = [
    Paragraph("Report with Image", styles["Title"]),
    Spacer(1, 0.3 * inch),
    Image("photo.jpg", width=3 * inch, height=2 * inch),
]
doc.build(story)

Image needs both width and height, otherwise it uses the image’s native pixel dimensions, which would likely be far too large for a PDF page.

Embedding Charts

ReportLab pairs well with matplotlib for charts. Save the matplotlib figure to a buffer, then embed it in the PDF:

import matplotlib.pyplot as plt
from io import BytesIO
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import inch

# Create a matplotlib chart
fig, ax = plt.subplots()
ax.bar(["Q1", "Q2", "Q3", "Q4"], [120, 190, 160, 210])
ax.set_title("Quarterly Revenue")

# Save to a BytesIO buffer
img_buffer = BytesIO()
fig.savefig(img_buffer, format="png", dpi=150)
img_buffer.seek(0)
plt.close(fig)

# Embed in PDF
doc = SimpleDocTemplate("chart.pdf", pagesize=A4)
styles = getSampleStyleSheet()

story = [
    Paragraph("Revenue Report", styles["Title"]),
    Spacer(1, 0.3 * inch),
    Image(img_buffer, width=5 * inch, height=3 * inch),
]
doc.build(story)

This pattern works for any matplotlib chart — bar charts, line plots, scatter plots, heatmaps.

Your First PDF with fpdf2

fpdf2 has a lighter API that works well for invoices and simple reports. Here is a basic PDF:

from fpdf import FPDF

# Create an A4 PDF in portrait mode, using millimeters as the unit
pdf = FPDF(format="A4", unit="mm")
pdf.add_page()

# set_font must be called before any text method
pdf.set_font("Helvetica", size=16)

# cell() draws a rectangular box containing text
# w=0 means extend to the right margin
# new_x="LMARGIN" and new_y="NEXT" move the cursor to the next line after the cell
pdf.cell(w=0, h=10, text="Hello, fpdf2!", align="C", new_x="LMARGIN", new_y="NEXT")
pdf.ln(10)  # Add 10mm of vertical space

pdf.set_font("Helvetica", size=12)
pdf.multi_cell(w=0, h=6, text="This cell handles line wrapping automatically. " * 5)

# output() renders the PDF; without a filename it returns bytes
pdf.output("hello_fpdf2.pdf")

fpdf2 uses the concept of a cell — a rectangular box that holds text. After drawing a cell, the cursor moves to the right by default. Setting new_x="LMARGIN" and new_y="NEXT" makes the cursor move to the next line instead, which is more natural for building a document top to bottom.

The multi_cell() method wraps long text across multiple lines automatically, which is convenient for body paragraphs. cell() only holds a single line.

Fonts and Colors

fpdf2 ships with 14 built-in PDF fonts. You can also register TrueType fonts for Unicode support:

pdf.add_font("DejaVu", style="", fname="DejaVuSans.ttf")
pdf.set_font("DejaVu", size=12)
pdf.set_text_color(33, 37, 41)       # RGB values 0-255
pdf.set_draw_color(74, 144, 226)    # line/border color
pdf.set_fill_color(245, 245, 245)   # background color
pdf.cell(0, 10, "Colored text!", fill=True)

Setting font size is always in points, not the document unit — the one exception in fpdf2’s dimensional system.

Custom Fonts with add_font()

For non-Latin characters, register a TrueType font:

pdf.add_font("DejaVu", "", "DejaVuSans.ttf")
pdf.set_font("DejaVu", size=12)
pdf.cell(0, 10, "Hello, \u4e16\u754c!")  # "Hello, 世界!"

Font files are resolved relative to the script’s working directory.

Page Layout Methods

# Set margins (left, top, right)
pdf.set_margins(20, 25, 20)

# Enable automatic page breaks
pdf.set_auto_page_break(auto=True, margin=15)

# Move cursor to absolute position
pdf.set_y(100)
pdf.set_x(50)
pdf.set_xy(50, 100)

Adding Images

pdf.image("photo.jpg", x=10, y=50, w=50)  # width in mm, height scales automatically

Supported formats: JPEG, PNG, GIF, BMP, TIFF, WebP.

Adding Page Numbers and Headers

Most real reports need page numbers and repeating headers. In ReportLab with Platypus, you pass callback functions to SimpleDocTemplate.build(). These callbacks run on each page:

from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import inch

def add_header_footer(canvas, doc):
    """Called for every page in the document."""
    canvas.saveState()

    # Header: document title on the left
    canvas.setFont("Helvetica", 9)
    canvas.drawString(1 * inch, A4[1] - 0.75 * inch, "My Report Title")

    # Footer: page number on the right
    page_num = canvas.getPageNumber()
    canvas.drawRightString(
        A4[0] - 1 * inch,  0.75 * inch,  # bottom-right corner
        f"Page {page_num}"
    )

    canvas.restoreState()

doc = SimpleDocTemplate("paged.pdf", pagesize=A4)
styles = getSampleStyleSheet()

story = [
    Paragraph("Page One", styles["Normal"]),
    Spacer(1, 6 * inch),  # Push content down to force a second page
    PageBreak(),  # Force a new page
    Paragraph("Page Two", styles["Normal"]),
]

# onFirstPage and onLaterPages can be different functions
# Here we use the same function for both
doc.build(story, onFirstPage=add_header_footer, onLaterPages=add_header_footer)

The callback gets the raw canvas and the document object as arguments. You can call any canvas method inside the callback to draw on the page. saveState() and restoreState() save and restore the canvas graphics state (current font, colors, etc.) so the header and footer do not interfere with the main content.

In fpdf2, you subclass FPDF and override the header() and footer() methods:

from fpdf import FPDF

class ReportPDF(FPDF):
    def header(self):
        self.set_font("Helvetica", "B", 12)
        self.cell(0, 10, "Report Title", align="C", new_x="LMARGIN", new_y="NEXT")
        self.ln(5)

    def footer(self):
        self.set_y(-15)  # Move to 15mm from the bottom
        self.set_font("Helvetica", "I", 8)
        self.cell(0, 10, f"Page {self.page_no()}", align="C")

pdf = ReportPDF()
pdf.add_page()
pdf.set_font("Helvetica", size=12)
pdf.cell(0, 10, "Hello, World!", new_x="LMARGIN", new_y="NEXT")
pdf.output("paged_fpdf2.pdf")

The header() and footer() methods are called automatically on every page, including the first. If you want different content on the first page, check self.page_no() inside these methods.

For the total page count, use alias_nb_pages() to register a placeholder, then use it in the footer:

class ReportPDF(FPDF):
    def footer(self):
        self.set_y(-15)
        self.set_font("Helvetica", "I", 8)
        self.cell(0, 10, f"Page {self.page_no()} of {self.alias_nb_pages()}", align="C")

pdf = ReportPDF()
pdf.add_page()
pdf.alias_nb_pages()
# ... add content ...

Your First PDF with WeasyPrint

WeasyPrint takes a completely different approach. You write HTML and CSS, then it renders a PDF:

from weasyprint import HTML

html = """
<!DOCTYPE html>
<html>
<head>
    <style>
        body { font-family: serif; margin: 2cm; }
        h1 { color: darkblue; }
    </style>
</head>
<body>
    <h1>Invoice #1234</h1>
    <p>Thank you for your order.</p>
</body>
</html>
"""

HTML(string=html).write_pdf("invoice.pdf")

HTML(string=...) accepts raw HTML. You can also load from a file with HTML(filename="input.html") or a URL with HTML(url="https://example.com").

WeasyPrint supports a useful subset of CSS for print: @page for margins and page size, Flexbox and Grid layouts, @font-face for custom fonts, and page-break-* for controlling where pages split. Web features like animations, transitions, and JavaScript do not work.

To set PDF compliance (e.g., PDF/A for archiving):

HTML(string=html).write_pdf("invoice.pdf", pdf_variant="pdf/a-1b")

Saving PDFs to Disk and Memory

All three libraries can save to a file on disk. For web applications, you often want to serve a PDF directly from memory instead of writing it to a file first.

ReportLab with BytesIO

from reportlab.platypus import SimpleDocTemplate, Paragraph
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.pagesizes import A4
from io import BytesIO

doc = SimpleDocTemplate("report.pdf", pagesize=A4)
styles = getSampleStyleSheet()
story = [Paragraph("Generated Report", styles["Title"])]

buffer = BytesIO()
doc.build(story, buffer)  # pass buffer as second positional argument

pdf_bytes = buffer.getvalue()

# Save to disk if you want:
with open("report_from_buffer.pdf", "wb") as f:
    f.write(pdf_bytes)

# Or serve via FastAPI:
# from fastapi.responses import Response
# return Response(pdf_bytes, media_type="application/pdf")

fpdf2 to Bytes

pdf = FPDF()
pdf.add_page()
pdf.set_font("Helvetica", size=12)
pdf.cell(0, 10, "Hello", new_x="LMARGIN", new_y="NEXT")

# output() without a filename returns bytes
pdf_bytes = pdf.output()

# Save to disk:
with open("output.pdf", "wb") as f:
    f.write(pdf_bytes)

WeasyPrint to Bytes

from weasyprint import HTML

html = HTML(string="<h1>Hello</h1>")
pdf_bytes = html.write_pdf()  # returns bytes when no target is given

This pattern works with Flask, FastAPI, Django, or any web framework where you want to stream a freshly generated PDF to the browser without touching the filesystem.

Summary

Each library has a distinct sweet spot:

  • Use ReportLab when you need fine-grained programmatic control — custom layouts, exact positioning, embedded charts, or complex multi-page documents. Its Canvas API gives you pixel-level control. Platypus makes document-style layouts more manageable. Pair it with matplotlib for charts.

  • Use fpdf2 when you need a lightweight API for straightforward PDFs — invoices, forms, letters. It has a lower learning curve than ReportLab and handles the common cases without overhead.

  • Use WeasyPrint when you have existing HTML/CSS templates or your content is easier to express in a web format. If you already generate web pages, you can reuse those templates for PDF output. You lose procedural drawing entirely — WeasyPrint is one-way (HTML → PDF).

Featurefpdf2WeasyPrintReportLab
ApproachProcedural cellsHTML/CSSCanvas + Platypus
Learning curveLowLowMedium–high
ChartsVia external libsCSS/canvasBuilt-in
UnicodeVia TrueType fontsNativeVia TrueType fonts
DependenciesMinimalHeavy (Pango, cairo)Moderate

See Also

  • Matplotlib Basics — matplotlib for charts and data visualization
  • File I/O in Python — reading and writing files, including CSV and binary formats
  • pypdf Docs — for reading and manipulating existing PDFs (different from generation)