How to convert HTML to PDF in Python
Python has more HTML-to-PDF options than most languages, and they differ a lot in what CSS they understand and what they cost to run. This guide covers the two that hold up in production, WeasyPrint and Playwright, then shows how to offload rendering to PDFOven with a plain requests call.
Option 1: WeasyPrint
WeasyPrint is a pure layout engine written in Python. It does not run a browser and does not execute JavaScript. In exchange it is lightweight, deterministic and has excellent support for CSS Paged Media: @page rules, running headers and footers, page counters and named pages.
pip install weasyprint
WeasyPrint relies on Pango for text layout, so on Linux you also need system packages (on Debian or Ubuntu, libpango-1.0-0 and libpangoft2-1.0-0), and on macOS brew install pango.
from weasyprint import HTML, CSS
html = """
<h1>Invoice #1042</h1>
<p>Thanks for your business.</p>
"""
HTML(string=html, base_url=".").write_pdf(
"invoice.pdf",
stylesheets=[CSS(string="@page { size: A4; margin: 0.5in; }")],
)
Page numbers are pure CSS:
@page {
@bottom-center {
content: "Page " counter(page) " of " counter(pages);
font-size: 9pt;
}
}
Trade-offs: no JavaScript means client-rendered charts or anything built by a framework will not appear. Flexbox and grid support has improved in recent releases but is not as complete as a browser engine, so layouts designed in Chrome can shift. If your templates are server-rendered and print-oriented, WeasyPrint is a great fit.
Option 2: Playwright (headless Chromium)
When you need pixel parity with Chrome or must run JavaScript, drive a real browser:
pip install playwright
playwright install chromium
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.set_content(html, wait_until="networkidle")
page.pdf(
path="invoice.pdf",
format="A4",
print_background=True,
margin={"top": "0.5in", "bottom": "0.5in", "left": "0.5in", "right": "0.5in"},
)
browser.close()
In a web app (Django, Flask, FastAPI) use the async API or run rendering in a worker, because launching Chromium per request is slow. Trade-offs: Chromium is a large dependency (hundreds of megabytes on disk and in memory), playwright install --with-deps pulls in many system libraries, and slim Docker images need fonts installed for non-Latin scripts and emoji. On AWS Lambda the browser does not fit in a regular zip package, so you end up building a container image and paying a cold start.
A note on pdfkit and xhtml2pdf
pdfkit is a wrapper around wkhtmltopdf, whose repository was archived in 2023 and which uses an old WebKit without flexbox or grid. xhtml2pdf uses ReportLab and supports only a subset of CSS 2. Both are fine for very simple documents but are not a good foundation for new projects.
The PDFOven way
If you would rather not ship Chromium or Pango, send the HTML to PDFOven and get the PDF bytes back:
import os
import requests
resp = requests.post(
"https://pdfoven.com/v1/pdf",
headers={"Authorization": f"Bearer {os.environ['PDFOVEN_KEY']}"},
json={
"html": html,
"css": "body { font-family: Inter, sans-serif; }",
"options": {"format": "A4", "margin": "0.5in", "print_background": True},
},
timeout=60,
)
if not resp.ok:
err = resp.json()["error"]
raise RuntimeError(f"{err['code']}: {err['message']}")
with open("invoice.pdf", "wb") as f:
f.write(resp.content)
Because rendering happens in Chromium, JavaScript runs and modern CSS works. If your page draws a chart after loading data, add an element when it is done and pass "wait_for_selector": "#chart-ready".
Returning the PDF from Django or FastAPI
from fastapi.responses import Response
@app.get("/invoices/{invoice_id}.pdf")
def invoice_pdf(invoice_id: int):
html = render_invoice_html(invoice_id)
resp = requests.post(
"https://pdfoven.com/v1/pdf",
headers={"Authorization": f"Bearer {os.environ['PDFOVEN_KEY']}"},
json={"html": html, "options": {"format": "Letter"}},
timeout=60,
)
resp.raise_for_status()
return Response(
content=resp.content,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="invoice-{invoice_id}.pdf"'},
)
Templates instead of HTML strings
If you are already rendering Jinja templates you can keep doing that. Alternatively, store a Handlebars template in PDFOven and send only data:
requests.post(
"https://pdfoven.com/v1/pdf",
headers={"Authorization": f"Bearer {os.environ['PDFOVEN_KEY']}"},
json={"template_id": "invoice", "data": {"number": 1042, "items": items}},
timeout=60,
)
Base64 responses
For Celery tasks or JSON-only pipelines, set "response": "base64" and you get {"data", "content_type", "bytes"} back instead of binary.
Choosing
- WeasyPrint for print-first documents with no JavaScript, when you want everything in-process.
- Playwright when you need Chrome fidelity and are happy to operate a browser.
- PDFOven when you want Chrome fidelity without the browser: 100 free renders a month, then $12, $39 or $99 for 2,000, 15,000 or 60,000. A
429with codequota_exceededtells you when you have hit the limit. Generated files are never stored.
Skip the browser plumbing
PDFOven runs Chromium for you. Send HTML, Markdown, a URL or JSON to a template and get a PDF back. 100 free renders every month.
Get a free API key