Generating PDFs in 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).
| Feature | fpdf2 | WeasyPrint | ReportLab |
|---|---|---|---|
| Approach | Procedural cells | HTML/CSS | Canvas + Platypus |
| Learning curve | Low | Low | Medium–high |
| Charts | Via external libs | CSS/canvas | Built-in |
| Unicode | Via TrueType fonts | Native | Via TrueType fonts |
| Dependencies | Minimal | Heavy (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)