HTML to PDF in Python: does your HTML need a browser?
Updated by Cyril Nicodème
In Python, picking an HTML-to-PDF library comes down to one question: does your HTML need a browser? We sent the same invoice through four Python routes. Its one inline script ran only in the two that use Chromium. So if your HTML runs JavaScript (a chart drawn by a script, totals filled in on load) or the PDF has to match what Chrome shows, you need Chromium: Playwright on your own servers, or one requests.post to the PDFShift API.
Which Python routes got our test invoice right?
Our test file is an invoice in 7,992 bytes of HTML. A flex header sits on top. The bill-to and ship-to addresses share a two-column CSS grid. Below them, a 45-row <table> with a <thead> runs onto page 2, and tr { break-inside: avoid } keeps each row whole. An @page rule asks for A4 and a "Page X of Y" footer in the bottom-right margin. One inline script writes "JS ran" into an empty element. Every route got that file unchanged.
Method. One Apple Silicon Mac (macOS 27.0.1, Python 3.15), November 12, 2026. WeasyPrint 70.0, xhtml2pdf 0.2.28, Playwright 1.65.1 with its Chromium build 1254, and requests 2.34.2 for the API call. Each route printed A4 with its defaults, plus only the options in the code below. Times are the median of 3 runs.
What does WeasyPrint need on your server?
A pip install, and a system library that pip can't install for you. The Python side is short:
from weasyprint import HTML
# html can be your Django or Jinja template rendered to a string
HTML(string=html).write_pdf("invoice.pdf")
On our invoice, WeasyPrint got everything but the script right: the flex header, the two-column grid, the header row repeated on page 2 and the @page footer, with the table split 32 rows and then 13. It took a median 110 ms per PDF. If your HTML loads images or stylesheets by relative path, pass base_url as well.
The system library is Pango. WeasyPrint 70.0 needs Pango 1.44 or newer on every server that renders PDFs. On Debian:
Installing it isn't always smooth: in an r/Python thread from January asking for HTML-to-PDF libraries, the person who started it reported a WeasyPrint "dll issue".
Our invoice is a simple layout. WeasyPrint's API reference says flex "works for simple use cases but is not deeply tested", and grid "works for simple cases, but has some limitations". Run your own template through it once before you build on it.
Why did xhtml2pdf turn the invoice into 3 pages?
xhtml2pdf is pure Python, built on ReportLab. pip install xhtml2pdf is all it takes, with nothing outside pip. It made our PDF in 75 ms.
Its own log names the first problem: "Ignoring CSS properties xhtml2pdf does not implement: border-collapse, grid-template-columns". Its docs say it treats grid as a plain block, so the bill-to and ship-to addresses stacked on top of each other. The fonts came out different, the item descriptions wrapped onto two lines, and the 45 rows needed 3 pages (13, 18 and 14 rows). The header row did repeat on pages 2 and 3.
The @page footer never printed. xhtml2pdf takes page numbers from its own tags, <pdf:pagenumber /> and <pdf:pagecount />, placed in an @frame footer.
Its docs promise HTML5 and CSS 2.1 "and some of CSS 3". So for HTML written for the web, the pip-only install doesn't save you work: you'd keep a second copy of the template written for xhtml2pdf, and update both every time.
Is pdfkit still worth using?
No. The top answer on a Stack Overflow question about this still uses pdfkit.from_url, but pdfkit is a wrapper around wkhtmltopdf, and wkhtmltopdf's GitHub repository was archived on January 2, 2023. Its status page says the WebKit inside it "hasn't been updated since 2012", and warns that running it on untrusted HTML "can lead to complete takeover of the server it is running on". pdfkit's last release, 1.0.0, came out in November 2021. We left it out of our test.
pyhtml2pdf is the other wrapper that comes up. It drives headless Chrome through Selenium, so it's the Chromium route below with Selenium added on top.
What does running Chromium from Python cost?
About 632 MB on our Mac before the first PDF, and a browser process you keep running after it.
Playwright's 48 MB of wheels (the pip packages) are the small part. The default playwright install chromium then downloaded about 584 MB more: the full Chromium (376 MB), the headless shell (205 MB) and ffmpeg (2.9 MB). page.pdf() only needs the headless shell, and --only-shell skips the full Chromium. That still leaves 205 MB of browser to deploy, keep updated and run.
This is the Playwright code we ran, with the sync API:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch() # launch once, reuse for every PDF
page = browser.new_page()
page.set_content(html, wait_until="load")
page.pdf(
path="invoice.pdf",
format="A4",
print_background=True,
prefer_css_page_size=True,
)
page.close()
browser.close()
Each option changes a default. Without format, Playwright prints US Letter. Without print_background=True, background colors don't print. Without prefer_css_page_size=True, it ignores the page size in your @page rule.
Playwright passed all four checks on 2 pages, 28 rows on the first and 17 on the second. It took 76 ms per PDF with the browser already open. Launching the browser took 1.4 s, so start it once when your worker starts and open a new page for each PDF.
In a Docker image, --with-deps adds the system libraries the browser needs:
From then on, a browser is part of your production stack, and you handle its memory use and timeouts. Our Playwright production guide covers both.
How do you get Chromium's PDF in Python without running a browser?
Send the HTML to the PDFShift API. We run Chromium, and the PDF comes back as the response body. On your side, that's requests and nothing else:
import os
import requests
# html can be your Django or Jinja template rendered to a string
response = requests.post(
"https://api.pdfshift.io/v3/convert/pdf",
headers={"X-API-Key": os.environ["PDFSHIFT_API_KEY"]},
json={"source": html, "format": "A4", "use_print": True},
)
response.raise_for_status()
with open("invoice.pdf", "wb") as f:
f.write(response.content)
Like Playwright, it passed all four checks on 2 pages, in 1.8 s with the network round trip.
Keep use_print: True in the body. Without it, our PDF lost the header row on page 2.
You can run that code before you sign up. Leave out the X-API-Key header and the call runs as a free account: the PDF gets a watermark, and the limits are 2 MB, 30 seconds and 10 calls a day. That's how we ran our test. While you build, set sandbox to True to get watermarked PDFs that don't use your credits.
With a key, the free plan gives you 50 credits a month, no credit card. Get a free API key, and find every parameter in the API docs. If your project uses aiohttp, urllib3 or httplib2 instead of requests, our Python guides show the same call with each client.
We hope this guide was helpful. If one of these snippets doesn't work on your setup,
feel free to drop us a line.