mrkeyoor.com_
Thu 06 Aug 07:40 UTC
PyPIDataupdated 06 Aug 2026

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.

Verdict

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.

API stability5/5Code written against canvas and Platypus a decade ago still runs. The 4.0 removal of C extensions was invisible to callers, and 5.0.0 is essentially 4.5.1 plus a tightened default for trustedHosts in open_for_read, which only affects documents that load remote resources
Docs2/5The user guide, graphics guide, and API reference are PDF manuals rather than a searchable site, examples are dated, and there is no docstring-generated reference for most classes. Working out Table splitting or page template behaviour usually means reading the source under src/reportlab
Maintenance3/5Releases are frequent and the CHANGES file shows security fixes shipping promptly through 4.4.x and 5.0.0, but development happens in a private Mercurial repository with no public tracker, so you cannot see open bugs, subscribe to a thread, or send a patch through normal channels
Ecosystem4/5The most widely used PDF generator in Python and the rendering layer under Django reporting tools, xhtml2pdf, and a long tail of invoicing code. Third-party extensions and Stack Overflow coverage are deep, though most answers date from the 3.x era

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
Skip it if

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

PackageRegistryPick it when
weasyprintPyPIYour document is already designed in HTML and CSS and you want the browser box model rather than hand-placed coordinates
fpdf2PyPIYou want a much smaller and easier API for simple documents and can live without Platypus-style automatic flow layout
pypdfPyPIYou need the half ReportLab cannot do: merging, splitting, rotating, stamping, or reading metadata out of PDFs that already exist
pymupdfPyPIOne library has to both create and read PDFs, and the AGPL or a commercial licence works for your project