pyguides

Generating PDFs in Python with ReportLab, fpdf2, and WeasyPrint

Introduction

PDFs are a common output format for Python applications, and generating PDFs programmatically is a frequent requirement in reporting, invoicing, and document automation workflows. 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. All three libraries are now available in your environment: ReportLab for programmatic PDF construction, fpdf2 for simpler documents, and WeasyPrint for HTML-to-PDF conversion. Each takes a different approach to the same problem, and the examples below let you compare them side by side.

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. Once you have text positioning down, the same coordinate system extends naturally to drawing basic shapes like rectangles, circles, and lines directly on the canvas:

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.

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 a PDF

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

The raw canvas approach gives you pixel-level placement control, which is useful when you need the image aligned precisely with other drawn elements. For documents where images flow naturally between paragraphs and headings, the Platypus API is often more practical. Platypus treats Image as just another flowable in the story, so it slots into the document layout without manual coordinate calculation:

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. The key insight is that you capture the matplotlib figure in memory rather than saving it to disk, then feed the in-memory buffer directly to ReportLab’s Image flowable. This avoids temporary files and keeps your PDF generation self-contained within a single script.

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. When you need more visual control beyond basic text, fpdf2 offers methods for setting colors and registering custom fonts, letting you match brand guidelines or highlight important data in your PDF output:

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. While the 14 built-in fonts cover Latin scripts well, documents that need Chinese, Japanese, Arabic, or other non-Latin characters require TrueType font registration with add_font(). Once registered, the font works just like any built-in one:

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. After setting up fonts and colors, you need control over where content lands on the page. fpdf2 provides several methods for positioning the cursor and controlling page flow: setting margins, enabling automatic page breaks, and moving the cursor to absolute coordinates on the page:

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)

Where set_margins(), set_auto_page_break(), and set_xy() control page geometry, fpdf2 also makes it straightforward to embed raster images anywhere on the page. You supply a file path, x and y coordinates in the document’s unit (millimeters here), and a width. fpdf2 scales the height automatically to preserve the original aspect ratio so images never appear stretched or squashed. This is especially useful for invoices and reports where a company logo sits in the header or product photos accompany line items.

Adding images in fpdf2

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

Supported formats: JPEG, PNG, GIF, BMP, TIFF, WebP. Once you have the basics of content layout down, the next requirement for any production PDF is consistent page numbering and headers that repeat across every sheet, a feature handled differently by each library:

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. The Platypus callback approach gives you direct canvas access at the cost of managing state manually, which works well for pixel-precise headers.

In fpdf2, the same concept takes a different shape. Instead of passing callback functions, you subclass the FPDF class and override its built-in header() and footer() methods. These run automatically on every page without needing explicit state management, making fpdf2’s approach feel more like filling in a template than writing raw drawing instructions:

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. The basic page numbering above works well for simple documents but there is a catch: the current page number is easy to obtain, but the total number of pages is not available until the entire document finishes rendering. For documents that need a “Page 3 of 12” format, fpdf2 uses a two-step mechanism built around a special alias token. You register it once early in the document, it sits as a placeholder during content construction, and then fpdf2 replaces every occurrence with the final page total at the very end of the rendering process. This deferred-replacement approach is necessary because page count cannot be known before all content has been laid out and any automatic page breaks have been resolved. Call the alias registration method once after creating the first page but before adding any content that references it in a header or 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 ...

fpdf2 handles the common document generation cases well, but its procedural cell-by-cell approach has limits. When you need complex multi-column layouts or want to reuse HTML templates you already have, a different strategy becomes more practical. This is where WeasyPrint fits in: instead of building PDFs from primitive drawing commands, you describe the document in HTML and CSS and let the rendering engine handle layout calculations entirely.

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, including at-page rules for margins and page dimensions, Flexbox and Grid for layout control, font face declarations for embedding custom typefaces, and break properties for controlling exactly where pages split during rendering. Web features like animations, transitions, and JavaScript do not work because WeasyPrint processes a static DOM and converts it directly to vector output. Beyond standard PDF generation, WeasyPrint can also produce PDF/A-compliant documents, which are suitable for long-term archiving and meet legal document preservation requirements. The pdf variant parameter accepts several compliance levels that govern font embedding requirements, color space restrictions, and metadata standards enforced by the PDF specification. This is important when generating documents that must remain readable decades from now, such as tax filings, contracts, or government submissions where archival validity matters.

To set PDF compliance (for example, PDF/A for archiving):

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

Whichever library you choose, the generated PDF eventually needs to reach its destination. For command-line scripts and desktop tools, writing to a file on disk is the natural default. But for web applications and API services, the PDF usually needs to be returned as an HTTP response directly from memory, without ever touching the filesystem. This in-memory pattern avoids disk I/O, simplifies cleanup, and works seamlessly with frameworks like Flask, FastAPI, and Django. The following examples show how each library handles this.

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

fpdf2 takes an even simpler approach than ReportLab: calling output() without a filename argument returns the entire PDF as a bytes object, ready for streaming or saving. There is no need to create a separate buffer first, which makes fpdf2 particularly convenient for web endpoints that generate simple documents on the fly:

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

WeasyPrint follows the same convention: omit the filename parameter and write_pdf() returns bytes instead of writing to disk. This consistency across the three libraries means you can swap between them without changing your application’s PDF delivery architecture. Whether you choose ReportLab for complex layouts, fpdf2 for quick documents, or WeasyPrint for HTML-driven content, the in-memory pattern stays the same:

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 like invoices, forms, and 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 since WeasyPrint is one-way (HTML to PDF).

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

Frequently asked questions

Which library should I use for invoices? fpdf2 is the best fit: it has a simple cell-based API, built-in page numbering, and minimal dependencies. For invoices that need precise table formatting with alternating row colors, ReportLab’s Table class with TableStyle gives you more control but requires more code.

Can I add watermarks to existing PDFs? None of the three libraries covered here support editing existing PDFs. For adding watermarks, merging pages, or extracting text from existing PDF files, use pypdf (formerly PyPDF2). The workflow is: generate your content PDF with one of these libraries, then use pypdf to overlay watermarks or combine pages.

Why does WeasyPrint need system dependencies? WeasyPrint relies on Pango for text layout, Cairo for rendering, and Fontconfig for font discovery; these C libraries handle the complex text shaping and layout algorithms that browsers use. They cannot be installed via pip, which is why WeasyPrint’s installation is heavier than ReportLab or fpdf2. On Linux, install them with your package manager; on macOS, they come with the system; on Windows, use the pre-built WeasyPrint installer.

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)