TL;DR
There is no single "best" Python library for HTML to PDF — the right pick depends on whether your HTML uses JavaScript, how strict your CSS is, and how much system-level install pain you can tolerate. WeasyPrint is the best pure-Python option for modern CSS. Playwright wins when the page needs a real browser to render (React, charts, dynamic content). pdfkit + wkhtmltopdf is the classic legacy pick that still ships. Everything else is either narrower or older. If you just need a PDF and do not want to write any Python at all, use Convert: Anything to PDF.
TL;DR: pick per use case
The seven libraries in this guide are not interchangeable. Pick by the shape of the input, not the shape of the code.
- Static HTML with modern CSS (Grid, Flexbox,
@page, custom fonts). Use WeasyPrint. It renders CSS spec-accurately, produces small files, has no headless browser dependency, and installs frompipwith a couple of system libraries on Linux. - HTML that requires JavaScript to render (React, Vue, D3, embedded charts). Use Playwright. It is a full Chromium and pays back the install cost with pixel-perfect fidelity. Everything else fakes it.
- Existing wkhtmltopdf install, or you need CSS 2.1 + a subset of CSS 3 for legacy templates. Use pdfkit as the Python wrapper. It is thin, well documented, and the binary handles the rendering.
- Report generation from data (invoices, statements, financial reports). Use ReportLab directly. HTML is not really the right input format here; ReportLab draws PDFs primitively and gives you exact control.
- Constrained environment (App Engine, some serverless runtimes) where you cannot install system binaries. Use xhtml2pdf. It is pure Python, install-simple, and CSS-limited — accept the tradeoff.
- Legacy codebase pinned to pyppeteer. Migrate to Playwright when you can. pyppeteer is barely maintained.
- You do not want to write Python at all. A browser extension like Convert: Anything to PDF or the PDF workflow in Convert Web to PDF is faster than any of this.
The rest of the article proves out each of those picks with code, install notes, and honest tradeoffs.
The comparison matrix
The at-a-glance ranking. Sort by whichever column matters to you.
| Library | CSS support | JS support | Install pain | License | Best for |
|---|---|---|---|---|---|
| WeasyPrint | Excellent (CSS 3, @page, print media) | None | Medium (Cairo/Pango on Linux) | BSD-3 | Modern CSS, invoices, reports |
| Playwright | Chromium-native (perfect) | Full | High (~200 MB) | Apache-2.0 | React/SPAs, dynamic pages |
| pdfkit (wkhtmltopdf) | CSS 2.1 + partial CSS 3 | Partial (old WebKit) | Medium (Qt binary) | LGPL / GPL2 | Legacy templates, existing installs |
| xhtml2pdf | Limited (CSS 2.1 subset) | None | Trivial (pure Python) | Apache-2.0 | Locked-down or serverless environments |
| ReportLab | N/A (not HTML-based) | N/A | Trivial | BSD / commercial | Programmatic report generation |
| pyppeteer | Chromium-native | Full | High + unmaintained | MIT | Nothing new. Migrate off. |
| wkhtmltopdf (direct) | CSS 2.1 + partial CSS 3 | Partial | Medium | LGPL / GPL2 | Shell-only pipelines |
"Install pain" is the honest measure most guides skip. WeasyPrint needs Cairo and Pango installed at the OS level — Ubuntu handles this in one apt line, macOS wants Homebrew, Windows is a whole afternoon. Playwright downloads a full browser binary the first time you run it, which is 200 MB you do not want in every Docker layer. pdfkit needs the wkhtmltopdf binary in PATH. Only xhtml2pdf and ReportLab install cleanly with pip alone.
License matters for commercial redistribution. WeasyPrint (BSD), Playwright (Apache), and xhtml2pdf (Apache) are unrestricted. wkhtmltopdf is LGPL for the library and GPL2 for the binary — fine for services, awkward for shipping desktop apps that bundle it. ReportLab has a free BSD tier and a paid Plus tier for larger features.
1. WeasyPrint (best CSS support)
WeasyPrint is what you reach for when the HTML input is already styled correctly with modern CSS and you want a small, deterministic output. It parses HTML and CSS from scratch — no browser, no WebKit — and its CSS 3 implementation is closer to the spec than most browsers were five years ago. It fully honors @page rules, page-break controls, running headers, footnotes, and custom fonts. Output is a genuine tagged PDF with selectable text, real hyperlinks, and PDF bookmarks generated from your h1–h6 structure.
Install:
# Ubuntu / Debian
sudo apt install python3-pip libpango-1.0-0 libpangoft2-1.0-0
# macOS
brew install pango
pip install weasyprint
Minimal usage:
from weasyprint import HTML
HTML("https://example.com").write_pdf("out.pdf")
HTML(string="<h1>Hello</h1><p>World</p>").write_pdf("hello.pdf")
HTML(filename="invoice.html").write_pdf("invoice.pdf")
Real-world use — an invoice with a print stylesheet:
from weasyprint import HTML, CSS
HTML("invoice.html").write_pdf(
"invoice.pdf",
stylesheets=[CSS("print.css")],
optimize_images=True,
)
What kills WeasyPrint for you: JavaScript. If your invoice template computes totals with JS or your report renders a Chart.js graph, WeasyPrint sees the pre-execution DOM. Nothing runs. For anything dynamic, jump to Playwright.
Rendering speed is competitive with a headless browser for static content and materially faster on cold start because there is no browser to boot. A 20-page invoice on modest hardware renders in under 300 ms.
2. Playwright (best JS support)
If your HTML needs JavaScript to become the thing you want to print, Playwright is the answer. It drives a real Chromium instance under the hood, so anything Chrome can render — React apps, canvas charts, WebGL, lazy-loaded images, custom web components — comes out identical in the PDF.
Install:
pip install playwright
playwright install chromium
Minimal usage — render a live URL to PDF:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://example.com", wait_until="networkidle")
page.pdf(path="out.pdf", format="A4", print_background=True)
browser.close()
Render a local HTML string with fonts and print CSS:
from playwright.sync_api import sync_playwright
html = "<h1>Q3 report</h1><p>Numbers go here.</p>"
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.set_content(html, wait_until="networkidle")
page.emulate_media(media="print")
page.pdf(
path="report.pdf",
format="Letter",
margin={"top": "1in", "bottom": "1in", "left": "0.5in", "right": "0.5in"},
print_background=True,
)
browser.close()
Two gotchas. First, wait_until="networkidle" is the honest option — "load" fires before your async data lands. Second, Playwright's PDF output only comes from Chromium; do not switch to firefox or webkit for page.pdf(), they do not implement it.
Cost: a Chromium install is around 200 MB. In Docker you either use the official mcr.microsoft.com/playwright/python image or accept a fat container. Cold-start is ~1–2 seconds; keep the browser warm across requests if you are running a service.
3. pdfkit + wkhtmltopdf
pdfkit is a thin Python wrapper around the wkhtmltopdf command-line binary. The binary is an old WebKit build with reasonable CSS 2.1 support and partial CSS 3. It is not modern — WebKit has moved on years ago — but it renders a lot of the templates people already wrote and it is extremely predictable.
Install:
# macOS
brew install wkhtmltopdf
# Ubuntu
sudo apt install wkhtmltopdf
pip install pdfkit
Usage:
import pdfkit
pdfkit.from_url("https://example.com", "out.pdf")
pdfkit.from_file("report.html", "report.pdf")
pdfkit.from_string("<h1>Hi</h1>", "hi.pdf")
# With options — headers, footers, page size
options = {
"page-size": "A4",
"margin-top": "0.75in",
"margin-bottom": "0.75in",
"encoding": "UTF-8",
"footer-right": "[page]/[topage]",
}
pdfkit.from_file("report.html", "report.pdf", options=options)
Reasons to use it: you inherited a codebase that already uses it, or your templates were designed for WebKit and rendering them anywhere else looks wrong. Reasons not to: the underlying binary has known security advisories, upstream wkhtmltopdf archived its main repo in 2023, and JavaScript support is a decade-old QtWebKit which increasingly fails on real-world pages. If you are starting fresh, WeasyPrint or Playwright is a better home.
4. xhtml2pdf, ReportLab
Two libraries with narrow but real uses.
xhtml2pdf is pure Python. No system binaries, no browser, pip install xhtml2pdf and you are running. It parses HTML and a subset of CSS 2.1 and writes PDF. The catch is that "subset" — no Flexbox, no Grid, spotty positioning, unpredictable table layout on complex documents.
from xhtml2pdf import pisa
with open("invoice.pdf", "wb") as f:
pisa.CreatePDF("<h1>Invoice</h1><p>Total: $199</p>", dest=f)
The right place for xhtml2pdf is a locked-down environment — a serverless runtime that will not let you install system libraries, or a corporate laptop where you cannot brew anything. Keep your templates simple and it will not embarrass you.
ReportLab is not really an HTML-to-PDF library. It is a PDF drawing library where you place text, lines, tables, and images with coordinates. If you are generating a report from structured data — invoices, bank statements, insurance quotes — ReportLab produces smaller, more consistent output than any of the HTML-based tools, because you never have to fight the browser box model.
from reportlab.pdfgen import canvas
c = canvas.Canvas("invoice.pdf")
c.setFont("Helvetica", 14)
c.drawString(100, 750, "Invoice #12345")
c.drawString(100, 730, "Total: $199.00")
c.save()
For anything more than a page of primitives, use the higher-level Platypus API (SimpleDocTemplate, Paragraph, Table, PageBreak). It has HTML-like markup for text, but the layout is yours to specify.
Reach for ReportLab when the input is data, not HTML. Reach for WeasyPrint when the input is real HTML.
5. pyppeteer (legacy)
pyppeteer is an unofficial Python port of Puppeteer. It predates Playwright and it does roughly the same job — drive Chromium headlessly, render a page, save a PDF. It has one purpose today: keeping legacy codebases running until they migrate.
import asyncio
from pyppeteer import launch
async def render():
browser = await launch()
page = await browser.newPage()
await page.goto("https://example.com")
await page.pdf({"path": "out.pdf", "format": "A4"})
await browser.close()
asyncio.run(render())
Why not to start here: the project is barely maintained, its bundled Chromium is often several versions behind, and Playwright's Python API is nearly identical, better documented, and actively developed by Microsoft. The migration is mostly a rename of a few methods. Do it.
Same fixture, all 7 libraries side-by-side
To make the difference concrete, here is the same source HTML rendered by all seven approaches. The fixture is intentionally boring — a heading, a paragraph, an image, a simple table — so the differences you see reflect the library, not the template.
Fixture (fixture.html):
<!doctype html>
<html>
<head><style>
body { font-family: system-ui; padding: 2rem; }
table { border-collapse: collapse; margin-top: 1rem; }
th, td { border: 1px solid #999; padding: 0.5rem 1rem; }
</style></head>
<body>
<h1>Q3 Report</h1>
<p>Revenue was up 12% year over year.</p>
<table>
<tr><th>Region</th><th>Revenue</th></tr>
<tr><td>NA</td><td>$4.2M</td></tr>
<tr><td>EMEA</td><td>$2.1M</td></tr>
</table>
</body>
</html>
Rendering the same fixture:
# 1. WeasyPrint
from weasyprint import HTML
HTML("fixture.html").write_pdf("out-weasyprint.pdf")
# 2. Playwright
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
b = p.chromium.launch(); pg = b.new_page()
pg.goto("file://" + __import__("os").path.abspath("fixture.html"))
pg.pdf(path="out-playwright.pdf"); b.close()
# 3. pdfkit
import pdfkit
pdfkit.from_file("fixture.html", "out-pdfkit.pdf")
# 4. xhtml2pdf
from xhtml2pdf import pisa
with open("fixture.html") as src, open("out-xhtml2pdf.pdf", "wb") as dst:
pisa.CreatePDF(src.read(), dest=dst)
# 5. ReportLab (not HTML — data-first equivalent)
from reportlab.lib.pagesizes import LETTER
from reportlab.platypus import SimpleDocTemplate, Paragraph, Table
from reportlab.lib.styles import getSampleStyleSheet
s = getSampleStyleSheet()
SimpleDocTemplate("out-reportlab.pdf", pagesize=LETTER).build([
Paragraph("Q3 Report", s["Heading1"]),
Paragraph("Revenue was up 12% year over year.", s["BodyText"]),
Table([["Region", "Revenue"], ["NA", "$4.2M"], ["EMEA", "$2.1M"]]),
])
# 6. pyppeteer
import asyncio
from pyppeteer import launch
async def run():
b = await launch(); pg = await b.newPage()
await pg.goto("file://" + __import__("os").path.abspath("fixture.html"))
await pg.pdf({"path": "out-pyppeteer.pdf"}); await b.close()
asyncio.run(run())
# 7. wkhtmltopdf (shell)
# $ wkhtmltopdf fixture.html out-wkhtmltopdf.pdf
What you will see when you open the seven files: Playwright, pyppeteer, and pdfkit look nearly identical (they are all WebKit-family). WeasyPrint is crisp and slightly tighter — its font metrics come from FreeType directly. xhtml2pdf will have visible spacing quirks in the table. ReportLab looks the most different — it is not trying to be a browser and it has its own layout style that is arguably more professional for reports.
For a broader look at what changes when the source is not HTML at all — Word docs, images, spreadsheets — see the complete format guide to converting anything to PDF. And for a no-code walkthrough of HTML-to-PDF specifically, the convert HTML to PDF guide covers browser Print, extensions, and API services.
Frequently asked questions
What's the best Python library for HTML to PDF?
There is no single best. Pick by the input: WeasyPrint for static HTML with modern CSS (invoices, reports, print-styled pages); Playwright for HTML that needs JavaScript to render (React apps, dashboards with charts, dynamic content); pdfkit + wkhtmltopdf if you already have wkhtmltopdf installed or you inherited templates designed for it; ReportLab if you are generating PDFs from structured data and HTML is not really your input; xhtml2pdf if you cannot install system binaries. For most greenfield projects with static HTML, WeasyPrint is the default answer.
WeasyPrint vs wkhtmltopdf — which is better?
WeasyPrint for new projects, wkhtmltopdf only for legacy. WeasyPrint has significantly better modern CSS support (Flexbox, Grid, custom fonts, @page), is actively maintained, produces smaller and tagged PDFs with selectable text, and installs from pip plus two system libraries. wkhtmltopdf is an archived upstream project built on old QtWebKit — its rendering is predictable but the security posture is worse and modern CSS often fails silently. The only reason to pick wkhtmltopdf today is that your existing templates depend on it.
How do I convert HTML to PDF in Python without a headless browser?
Use WeasyPrint or xhtml2pdf. Both parse HTML and CSS directly and emit PDF without booting a browser process. WeasyPrint is the better renderer (near-complete CSS 3 support, real print media handling, tagged PDF output) and needs Cairo/Pango installed at the OS level. xhtml2pdf is pure Python, installs cleanly from pip, and supports a subset of CSS 2.1 — enough for simple documents, not enough for complex layouts. Neither library executes JavaScript, so any HTML that requires JS to become its final form needs Playwright instead.
Does WeasyPrint support JavaScript?
No. WeasyPrint parses the HTML and CSS as delivered and renders the pre-execution DOM. If your page relies on JavaScript to fetch data, mount a React tree, draw a Chart.js graph, or compute totals, WeasyPrint will render whatever exists before that runs — usually a blank template. For JS-dependent pages use Playwright, which drives a real Chromium instance and lets the page finish executing before you call page.pdf(). A common workaround is to pre-render the page with a headless browser and then hand the resulting static HTML to WeasyPrint, but at that point Playwright doing the whole job is simpler.
What's the fastest Python HTML-to-PDF library?
For static HTML, WeasyPrint is the fastest end-to-end because there is no browser cold start — a 20-page document renders in a few hundred milliseconds and you can run it in an ordinary Python process. Playwright is fast per-page once the browser is warm (~50–150 ms per page) but pays a ~1–2 second cold start each time you launch Chromium, so it wins for high-volume services that keep a browser pool alive and loses for single-shot CLI scripts. pdfkit sits between the two — no cold browser start but a full binary invocation per call, typically 300–600 ms overhead per document.
Bottom line
Seven libraries, three real picks. WeasyPrint for static HTML with modern CSS. Playwright when JavaScript needs to run. pdfkit if you are living with an existing wkhtmltopdf install. Everything else is either a workaround for a constrained environment (xhtml2pdf), a data-first tool that is not really HTML-to-PDF (ReportLab), or a legacy port you should migrate off (pyppeteer, wkhtmltopdf direct). Pick by what your HTML actually needs, not by what tops the search results.
If you skipped the code because you just want a PDF right now, Convert: Anything to PDF drops any HTML, image, or document into a PDF from your browser in one click — no pip install, no Chromium download, no Cairo.