HTML to PDF in Python
Python has more HTML-to-PDF options than any other ecosystem, and each one asks for
something back. WeasyPrint wants Pango, cairo and GDK-PixBuf installed at the system
level — fine on your laptop, an afternoon on Alpine, and a recurring surprise every
time a base image moves. pdfkit wants a wkhtmltopdf binary whose rendering engine
predates flexbox. xhtml2pdf supports a subset of CSS 2.1. pyppeteer is
unmaintained; Playwright works and brings a browser with it.
This is one requests call and no system packages.
One-off render
Only requests, and even that is a convenience — urllib works identically.
import os
import requests
html = """<!doctype html>
<html><head><style>
@page { size: A4; margin: 20mm; }
body { font: 14px/1.6 system-ui, sans-serif; color: #16181d; }
h1 { font-size: 28px; margin: 0 0 4px; }
</style></head>
<body>
<h1>Invoice 2026-0142</h1>
<p>Issued 2026-08-08 · Due 2026-09-07</p>
</body></html>"""
response = requests.post(
"https://renderpaper.com/v1/render",
headers={"X-API-Key": os.environ["RENDER_API_KEY"]},
json={"template": html},
timeout=60,
)
response.raise_for_status()
with open("invoice.pdf", "wb") as f:
f.write(response.content)
print("wrote invoice.pdf")
RENDER_API_KEY=rs_live_… python invoice.py
Stored templates: send data, not markup
Keeping the document inside your Python means every visual change is a deploy. Store it once, then post data.
import os
import requests
TEMPLATE_ID = "your-template-id"
response = requests.post(
f"https://renderpaper.com/v1/templates/{TEMPLATE_ID}/render",
headers={"X-API-Key": os.environ["RENDER_API_KEY"]},
json={
"data": {
"InvoiceNumber": "2026-0142",
"Customer": {"Name": "Northwind Trading AB"},
"LineItems": [
{"Description": "Consulting", "Quantity": 12, "Amount": 14400},
{"Description": "Hosting", "Quantity": 1, "Amount": 4350},
],
"Total": 18750,
}
},
timeout=60,
)
response.raise_for_status()
with open("invoice.pdf", "wb") as f:
f.write(response.content)
The stored template is a Go html/template. The syntax is close enough to Jinja to
read without learning it:
<h1>Invoice {{.InvoiceNumber}}</h1>
<p>{{.Customer.Name}}</p>
<table>
{{range .LineItems}}
<tr><td>{{.Description}}</td><td>{{.Quantity}}</td><td>{{.Amount}}</td></tr>
{{end}}
</table>
Streaming without holding the file in memory
For large documents, stream the response rather than buffering it:
import os
import requests
with requests.post(
"https://renderpaper.com/v1/render",
headers={"X-API-Key": os.environ["RENDER_API_KEY"]},
json={"template": "<h1>Statement</h1>"},
stream=True,
timeout=120,
) as response:
response.raise_for_status()
with open("statement.pdf", "wb") as f:
for chunk in response.iter_content(chunk_size=64 * 1024):
f.write(chunk)
Why not WeasyPrint
WeasyPrint is genuinely good and its CSS Paged Media support is better than
Chromium's — named pages, @page :first, running elements and margin boxes all
work properly, and that is not a small thing for print typography.
The trade is the rest of CSS and the install. Flexbox support arrived late and grid is still not there, so a layout built with modern tools does not survive the trip. And it is not a pure-Python dependency: Pango, cairo and their headers have to exist in every environment you deploy to.
Choose WeasyPrint when the document is print-first and typographic, and you control the environment. Choose a browser renderer when the document is web-first and built with the same CSS as the rest of your product.
Why not pdfkit and wkhtmltopdf
pdfkit is a thin wrapper around the wkhtmltopdf binary, so it inherits that
engine's age. It is a fork of a WebKit from before flexbox and grid existed; modern
layouts collapse quietly rather than failing loudly, which is the worst way for a
document to be wrong. The project has been unmaintained for years.
It is still a reasonable choice for a fixed, simple document that nobody redesigns.
Try it without a key
https://renderpaper.com/sample.pdf is a real render, publicly served. The free
tier is 50 documents a month with no card.