reportlab
ReportLab is the long-standing Python library for generating PDF files programmatically. It gives you two layers that sit on top of each other. The low-level one is reportlab.pdfgen.canvas.Canvas, a drawing surface where you place text, lines, shapes, and images at explicit coordinates and call showPage() when you want a new page. The high-level one is Platypus, a layout engine where you build a list of flowable objects (Paragraph, Table, Image, Spacer, PageBreak) and hand it to a document template that measures everything, wraps it into frames, and paginates for you. There is also a vector graphics module with charts and barcodes that renders to PDF, SVG, PostScript, or bitmaps. It has been around since 2000, which shows in both directions: the API is unusually settled, and the design predates most of what modern Python looks like.
Still the default for generating PDFs from Python when layout precision matters, and 25 years of use means the odd corner you hit has almost certainly been hit before. Budget real time for the Platypus learning curve, and expect to add a second library for anything involving reading PDFs.
Use it if
- You need to produce invoices, statements, certificates, or reports with exact control over where every element lands on the page, in points, with no browser in the loop
- You are generating documents on a server and cannot afford a headless Chromium: ReportLab is pure Python since 4.0, so a container with pip install reportlab is the whole deployment
- Your documents are table-heavy and need repeating headers, per-cell styling, and automatic splitting across pages, which Platypus Table handles directly
- You want charts, barcodes, or QR codes drawn as vectors inside the PDF rather than pasted in as raster images
- You need to read or edit existing PDFs: ReportLab only writes. It cannot open a file, merge two documents, fill an existing form, or extract text, so you will be pairing it with pypdf or pikepdf
- You want to render HTML or CSS: Paragraph accepts a small XML-ish markup with tags like b, i, font, and para, and that is all. Handing it real HTML produces either an exception or wrong output, and WeasyPrint exists precisely for that job
- You are in a hurry: laying out a non-trivial document in Platypus means learning flowables, frames, page templates, styles, and the onFirstPage and onLaterPages callbacks, and the reference material is a set of PDF manuals rather than a searchable API site
- You need complex script text: right-to-left and Indic shaping are opt-in extras (rlbidi for bidirectional text, uharfbuzz for shaping) and TTFonts raises outright if you ask for shaping without uharfbuzz installed. CJK also means registering fonts by hand
- You want raster output from the graphics module: renderPM needs the rlPyCairo backend since the C extensions were dropped in 4.0, and without that extra installed it raises RenderPMError
- You expect an open development process: there is no public GitHub repository, no public issue tracker, and no pull requests. The changelog credits fixes to people who emailed in, and the higher-productivity template workflow is sold as the commercial RML product
Setup reality
pip install reportlab is straightforward now: 4.0 dropped the C extensions, so there is nothing to compile, and the only mandatory dependencies are Pillow and charset-normalizer. Python 3.9 or newer is required. The extras are where it gets fiddly. Raster output from the graphics module needs pip install reportlab[pycairo], which pulls rlPyCairo and freetype-py; text shaping needs reportlab[shaping] for uharfbuzz; right-to-left needs reportlab[bidi]; and reportlab[accel] installs rl_accel for a speed bump on text measurement. Fonts are the other tax: only the 14 standard PDF fonts work out of the box, so anything else means shipping the TTF file and calling pdfmetrics.registerFont(TTFont(...)) plus registerFontFamily to make bold and italic resolve. Security settings moved recently too. rl_config.trustedSchemes and trustedHosts govern what open_for_read will fetch, and as of 5.0.0 trustedHosts=None means no hosts are trusted, so an Image flowable pointed at a remote URL will refuse to load until you configure it.
Patterns
Draw directly on a page with the low-level canvascanvas-hello-world
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
c = canvas.Canvas('hello.pdf', pagesize=A4)
width, height = A4
c.setFont('Helvetica-Bold', 18)
c.drawString(20 * mm, height - 30 * mm, 'Invoice 2026-0041')
c.setFont('Helvetica', 10)
c.drawRightString(width - 20 * mm, height - 30 * mm, 'Page 1')
c.showPage()
c.save()The origin is the bottom-left corner, so y grows upward and a top margin is height minus your offset. Nothing is written until save(), and forgetting showPage() before save() loses everything drawn on the last page.
Let the layout engine paginate for youplatypus-flowing-document
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
styles = getSampleStyleSheet()
doc = SimpleDocTemplate('report.pdf', pagesize=A4,
leftMargin=20 * mm, rightMargin=20 * mm,
topMargin=20 * mm, bottomMargin=20 * mm)
story = [Paragraph('Quarterly Report', styles['Title']), Spacer(1, 6 * mm)]
for para in body_paragraphs:
story.append(Paragraph(para, styles['BodyText']))
doc.build(story)Paragraph parses its text as markup, so a literal ampersand or angle bracket in user data raises a parse error. Escape with xml.sax.saxutils.escape() before building the flowable. Flowables are consumed by build(), so a story list cannot be reused for a second document.
Build a table with repeating headersstyled-table
from reportlab.platypus import Table, TableStyle
from reportlab.lib import colors
from reportlab.lib.units import mm
data = [['SKU', 'Item', 'Qty', 'Price']] + rows
table = Table(data, colWidths=[30 * mm, 80 * mm, 20 * mm, 25 * mm], repeatRows=1)
table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#22303f')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('ALIGN', (2, 1), (-1, -1), 'RIGHT'),
('GRID', (0, 0), (-1, -1), 0.25, colors.grey),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f4f6f8')]),
]))
story.append(table)Cell coordinates are (column, row) and negative indices count from the end, which trips up everyone at least once. repeatRows=1 redraws the header on each page. A cell whose content is a plain string will not wrap, so wrap long text in a Paragraph.
Use a font that is not one of the standard 14register-ttf-font
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
pdfmetrics.registerFont(TTFont('Inter', 'fonts/Inter-Regular.ttf'))
pdfmetrics.registerFont(TTFont('Inter-Bold', 'fonts/Inter-Bold.ttf'))
pdfmetrics.registerFont(TTFont('Inter-Italic', 'fonts/Inter-Italic.ttf'))
pdfmetrics.registerFontFamily(
'Inter', normal='Inter', bold='Inter-Bold', italic='Inter-Italic',
)
styles['BodyText'].fontName = 'Inter'Without registerFontFamily, a b tag inside a Paragraph silently renders in the regular weight because ReportLab has no way to find the bold face. Register once at import time, not per request, since parsing a TTF on every call is measurable.
Add a header and page numbers to every pagepage-numbers-and-header
from reportlab.lib.units import mm
def decorate(canvas, doc):
canvas.saveState()
canvas.setFont('Helvetica', 8)
canvas.drawString(20 * mm, doc.pagesize[1] - 12 * mm, 'ACME Ltd')
canvas.drawCentredString(doc.pagesize[0] / 2, 12 * mm, f'Page {canvas.getPageNumber()}')
canvas.restoreState()
doc.build(story, onFirstPage=decorate, onLaterPages=decorate)These callbacks run outside the frame, so anything you draw sits on top of the flowing content and does not affect layout. saveState and restoreState are not optional: font and colour changes made here otherwise leak into the body text of the next page.
Return a PDF from a web handler without touching diskpdf-to-memory
from io import BytesIO
from reportlab.platypus import SimpleDocTemplate, Paragraph
buffer = BytesIO()
doc = SimpleDocTemplate(buffer, title='Invoice 41', author='ACME Ltd')
doc.build([Paragraph('Invoice 41', styles['Title'])])
pdf_bytes = buffer.getvalue()Every entry point that takes a filename also takes a file-like object. Call getvalue() after build() returns, and note that title and author here set the PDF metadata that shows in a viewer's tab, which is worth filling in.
Place an image without distorting itimages-and-scaling
from reportlab.platypus import Image
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
reader = ImageReader('logo.png')
iw, ih = reader.getSize()
target_w = 40 * mm
story.append(Image('logo.png', width=target_w, height=target_w * ih / iw))
# low-level equivalent, with transparency preserved
# c.drawImage('logo.png', 20 * mm, 250 * mm, width=target_w, preserveAspectRatio=True, mask='auto')The Image flowable has no preserveAspectRatio option, so compute the height yourself or the logo stretches. On the canvas, mask='auto' is what keeps PNG transparency from rendering as a black box.
Control where pages breakkeep-together-and-breaks
from reportlab.platypus import PageBreak, KeepTogether, CondPageBreak
from reportlab.lib.units import mm
story.append(KeepTogether([
Paragraph('Terms and Conditions', styles['Heading2']),
Paragraph(terms_text, styles['BodyText']),
]))
story.append(CondPageBreak(40 * mm))
story.append(Paragraph('Appendix', styles['Heading1']))
story.append(PageBreak())KeepTogether pushes the whole group to the next page rather than splitting it, so a group taller than one page still splits and you get a mostly empty page before it. CondPageBreak only breaks when less than the given height remains, which is the softer option for headings.
Draw a chart as vectors inside the PDFvector-chart
from reportlab.graphics.shapes import Drawing
from reportlab.graphics.charts.barcharts import VerticalBarChart
from reportlab.lib import colors
drawing = Drawing(400, 200)
chart = VerticalBarChart()
chart.x, chart.y = 40, 30
chart.width, chart.height = 330, 150
chart.data = [[12, 18, 9, 21]]
chart.categoryAxis.categoryNames = ['Q1', 'Q2', 'Q3', 'Q4']
chart.bars[0].fillColor = colors.HexColor('#2f6f4f')
drawing.add(chart)
story.append(drawing)A Drawing is itself a flowable, so it goes straight into the story with no conversion. Chart geometry is in points inside the drawing's own coordinate space, and axis ranges are computed unless you set valueAxis.valueMin and valueMax by hand.
Add a barcode or QR codebarcode-and-qr
from reportlab.graphics.barcode import createBarcodeDrawing
code128 = createBarcodeDrawing('Code128', value='SKU-00417', barHeight=15, humanReadable=True)
qr = createBarcodeDrawing('QR', value='https://example.com/invoice/41', width=80, height=80)
story.extend([code128, qr])createBarcodeDrawing returns a Drawing, so it drops into a story or a table cell directly. The accepted option names differ per symbology, and an unknown keyword is silently ignored rather than raising, so verify the output rather than trusting the call.
Password-protect the outputencrypt-pdf
from reportlab.lib import pdfencrypt
enc = pdfencrypt.StandardEncryption(
userPassword='open-me',
ownerPassword='admin-secret',
canPrint=1,
canModify=0,
canCopy=0,
strength=128,
)
doc = SimpleDocTemplate('secure.pdf', encrypt=enc)The permission flags are advisory: they are recorded in the PDF and honoured by well-behaved viewers, not enforced cryptographically. Only the userPassword actually blocks opening the file.
Allow remote images after the 5.0.0 default changetrusted-hosts-config
from reportlab import rl_config
# 5.0.0: trustedHosts=None now means no host is trusted
rl_config.trustedHosts = ['cdn.example.com']
rl_config.trustedSchemes = ['https', 'file', 'data']
story.append(Image('https://cdn.example.com/logo.png', width=120, height=40))Before 5.0.0 a None value meant no restriction; now it means nothing remote loads and the image raises instead. Set this explicitly rather than widening it to a wildcard, since it is the control that stops a document template from fetching arbitrary URLs.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| weasyprint | PyPI | Your document is already designed in HTML and CSS and you want the browser box model rather than hand-placed coordinates |
| fpdf2 | PyPI | You want a much smaller and easier API for simple documents and can live without Platypus-style automatic flow layout |
| pypdf | PyPI | You need the half ReportLab cannot do: merging, splitting, rotating, stamping, or reading metadata out of PDFs that already exist |
| pymupdf | PyPI | One library has to both create and read PDFs, and the AGPL or a commercial licence works for your project |