3 ways to generate PDFs in Python (and when to use each)
In Python you generate PDFs three ways: draw the document programmatically with ReportLab, render real HTML and CSS with WeasyPrint, or call a hosted Chromium-backed API and skip rendering infrastructure entirely. Use ReportLab when your layout is code-defined and you don't have HTML — it's pure-Python, dependency-free, and cheapest at very high volume, but you position everything by coordinate. Use WeasyPrint when your source is HTML you fully control and can live with its own CSS engine — good CSS support, but no JavaScript, browser-different layout, and real system-library and font setup pain. Use a hosted API when you need true browser fidelity — the same rendering as Chrome, JavaScript included — with zero ops. Short version: ReportLab for drawn documents, WeasyPrint for HTML you own, a hosted API for HTML you didn't write or fidelity you can't compromise.
The three approaches, in one line each
If you've shipped more than one PDF feature in Python, you've already met at least two of these. Each is a legitimate tool — the mistake is picking one for the wrong reason (usually "ReportLab is pure-Python, no dependencies") and paying for it later in either layout math or missing-font debugging. Here's the honest tradeoff for each, then a decision table and a note on the one thing people get wrong about cost.
Option 1: ReportLab (drawing commands, no HTML)
ReportLab builds a PDF by issuing drawing commands — place this text at these coordinates, draw this line, embed this image — directly against the PDF spec. There is no HTML and no CSS involved. It's pure Python (the open-source reportlab package), has no system-library dependencies, launches instantly, and is battle-tested at enormous scale. If your document is genuinely code-defined — a receipt, a shipping label, a single-column financial report, a boarding pass — it's excellent and effectively free per document.
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
c = canvas.Canvas("invoice.pdf", pagesize=A4)
# Coordinates are in points, measured from the bottom-left corner.
c.setFont("Helvetica-Bold", 20)
c.drawString(50, 790, "Invoice #1042")
c.setFont("Helvetica", 12)
c.drawString(50, 760, "Total: $240.00")
c.showPage()
c.save()That coordinate detail is the whole story. You are positioning content by hand from the bottom-left corner in points, not writing a layout that flows. The moment your document needs to look designed — text wrapping around an image, a multi-column layout, a table whose row heights depend on content, a web font with proper kerning — you're doing a lot of manual measurement, or reaching for ReportLab's higher-level Platypus flowables, which is a second layout system to learn. If your actual bottleneck is design fidelity rather than raw throughput, ReportLab usually costs more engineering time than it saves.
Option 2: WeasyPrint (real HTML and CSS, its own engine)
WeasyPrint renders HTML and CSS to PDF, so you write a layout instead of placing coordinates. This is the natural choice when your source is already HTML — an email template, a Jinja2-rendered report — and you want to style it with a stylesheet. Its CSS support is genuinely good: the box model, flexbox, tables, @page rules for margins and page numbers, custom fonts. For a lot of templated documents it's the sweet spot.
from weasyprint import HTML
html = """
<style>
body { font-family: sans-serif; }
h1 { color: #111; }
@page { size: A4; margin: 2cm; }
</style>
<h1>Invoice #1042</h1>
<p>Total: $240.00</p>
"""
HTML(string=html).write_pdf("invoice.pdf")Two honest caveats, because they're the ones that bite. First, WeasyPrint is its own rendering engine — not Chromium — so it does not run JavaScript and it does not render pixel-identically to a browser. A page that looks perfect in Chrome can lay out differently in WeasyPrint, and anything that depends on client-side JS (a charting library, a framework that hydrates on load) simply won't run. If your HTML was designed to be viewed in a browser, you're QA-ing it twice. Second, it links against native libraries — Pango, cairo, and friends — plus whatever fonts you expect to see in the output. That's fine on your laptop and a recurring source of pain in Docker and serverless: a missing libpango or an absent CJK/emoji font gives you a crash or blank glyphs in production while it renders cleanly locally. See the WeasyPrint alternative rundown for the specific failure modes and workarounds.
Option 3: a hosted, Chromium-backed API
You send HTML, a URL, or a template plus a JSON payload to an API and get a PDF back. Behind it is real Chromium, so the output is pixel-identical to what you'd see in a browser tab — JavaScript runs, web fonts load, @media print rules apply. You don't manage a browser, you don't write drawing commands, and you don't own the render infrastructure's failure modes. The tradeoff is a per-document fee and one network hop. This is what Docweave does: browser-grade fidelity behind a single REST call, with no Chromium in your own deployment.
import requests
resp = requests.post(
"https://docweave.dev/api/v1/pdf",
headers={
"Authorization": "Bearer dw_live_your_key",
"Content-Type": "application/json",
"Accept": "application/pdf",
},
json={
"source": {
"type": "html",
"html": "<h1>Invoice #1042</h1><p>Total: $240.00</p>",
},
"idempotencyKey": "invoice-1042",
},
)
resp.raise_for_status()
with open("invoice.pdf", "wb") as f:
f.write(resp.content)Note the Accept: application/pdf header — that's what makes the endpoint stream the raw PDF bytes back instead of a JSON envelope, so resp.content is the file. The idempotencyKey earns its place the first time a request fires twice: a retried call after a socket timeout, a Celery task redelivered, a user double-clicking submit. Pass the same key for the same logical document and you get the original result back instead of a second render (and a second charge). For invoices specifically — where the template rarely changes but the data always does — the template + JSON mode is a better fit than shipping full HTML on every call; see generating invoice PDFs in Python.
Decision table
- Your layout is code-defined (receipts, labels, single-column reports), you want zero native dependencies, and volume is very high: **ReportLab**.
- Your source is HTML you fully control, you don't need JavaScript, and you can own the Pango/cairo/font setup in your deploy target: **WeasyPrint**.
- You need true browser fidelity, your HTML runs JavaScript or came from a browser-first design, or you don't want to babysit rendering infrastructure at all: **a hosted Chromium API** like Docweave.
- You're rendering the same templated document over and over (invoices, certificates, statements): a hosted API's template + JSON mode usually beats hand-building either of the other two.
The cost note people get wrong
A hosted API charges per document, so the reflex is "that's obviously more expensive than a library that's free." At very high volume with simple, code-defined layouts, that reflex is correct in raw dollars — ReportLab really is cheaper per document because the marginal cost is CPU you already pay for. What that comparison leaves out is engineering time: the hours spent on coordinate math, the Docker image debugging when a font is missing, the on-call page when a WeasyPrint dependency breaks after a base-image bump. The right way to decide is to price both against your actual numbers rather than a gut feeling — run your volume through the PDF cost calculator and compare it against the concrete per-document rate on Docweave's pricing. Sometimes the library wins; sometimes an afternoon of saved font debugging pays for a year of API calls.
If the caller is an AI agent, not your code
One shape doesn't fit the table above: when the thing generating the PDF is an LLM agent rather than your application. If a user asks an assistant to "turn this into a PDF," you don't want the agent hand-writing requests boilerplate — you want it to call a tool. Docweave ships an open-source MCP server for exactly this, so any MCP-capable agent can render a PDF as a single tool call:
npx @docweave/mcpThe agent then calls generate_pdf with a source and an output path, and the same Chromium rendering as Option 3 happens without any HTTP plumbing in the agent's context. If that's your use case, it's a genuinely different integration than any Python library gives you.
FAQ
Can ReportLab render HTML and CSS?
No. ReportLab builds PDFs from drawing primitives — text placed at coordinates, lines, shapes, images — and its Platypus layer adds flowable layout, but neither parses HTML or CSS. If your source is HTML you already have, use WeasyPrint (its own CSS engine) or a hosted Chromium API. ReportLab is for documents you define in Python code.
Why does WeasyPrint look different from Chrome, or crash in Docker?
Because WeasyPrint is its own rendering engine, not Chromium — it doesn't run JavaScript and its CSS layout can differ from a browser's, so HTML designed for the browser needs separate QA. The crashes are usually missing native libraries (Pango, cairo) or absent fonts in the minimal Docker/serverless image: it renders fine locally where those exist, then fails in production. A Chromium-backed hosted API sidesteps both since rendering happens off your infrastructure.
Which is fastest for generating PDFs in Python?
For simple, code-defined documents ReportLab is fastest — it's pure Python with no browser to launch and no network hop. WeasyPrint is slower because it parses and lays out CSS. A hosted API adds a network round-trip but moves the actual rendering off your process, so it scales without consuming your own CPU or memory. Fastest per-document isn't the same as fastest to build or cheapest to operate.
Do I need an SSRF guard if I render a URL to PDF in Python?
Yes. Any code that fetches a user-supplied URL and renders it is a textbook SSRF primitive — a caller can point it at internal metadata endpoints or private services. If you render URLs yourself with WeasyPrint or a headless browser, you must validate and restrict the target and isolate egress. A hosted API that guards URLs and runs rendering egress-isolated removes that burden from your code.
Related
Generate your first PDF in minutes.
Get an API key