Menu
Sign up

HTML to PDF in Python: WeasyPrint, xhtml2pdf, pdfkit and Playwright on the same invoice

We put one invoice through WeasyPrint, xhtml2pdf, pdfkit (wkhtmltopdf) and Playwright: CSS grid, repeated headers, page numbers, fonts and time per PDF.

Published November 8, 2026 · 4 min read

Python has four common ways to turn HTML into a PDF, and they differ far more than their one-line examples suggest: two of them render modern CSS correctly, two of them quietly do not. We put one invoice — a flexbox header, a CSS grid for the two addresses, a 45-row table that runs onto a second page, a web font, and page numbers set in CSS — through all four and compared the output and the time per PDF.

How we tested. On 2026-11-08, in the official mcr.microsoft.com/playwright/python:v1.64.1-noble image (Ubuntu 24.04), with WeasyPrint 70.0, xhtml2pdf 0.2.28, pdfkit 1.0.0 with wkhtmltopdf 0.12.6, and Playwright 1.64.1 with its Chromium 156. Each library rendered the same file five times; times are medians on a busy shared machine, so treat them as rough. Playwright's time is for an already-open browser.

What came out

WeasyPrint 70 xhtml2pdf 0.2.28 pdfkit / wkhtmltopdf 0.12.6 Playwright 1.64 (Chromium)
Needs a browser No No No (bundled old WebKit) Yes
Flexbox header Yes Yes Yes Yes
CSS grid (two address columns) Yes No — stacked No — stacked Yes
Table header repeated on page 2 Yes Yes No Yes
"Page 1 of 2" from @page margin box Yes No No Yes
Web font from @font-face Yes Yes Yes Yes
Page size honoured A4 A4 A4, but content scaled down A4
Rows on page 1 22 22 36 (shrunk) 22
Time per PDF 515 ms 83 ms 259 ms 30 ms
File size 18 KB 23 KB 25 KB 69 KB

xhtml2pdf said so itself: "Ignoring CSS properties xhtml2pdf does not implement: border-collapse, grid-template-columns". wkhtmltopdf applies "smart shrinking" by default, so it fitted 36 rows on page 1 in smaller type; its page 2 started straight with a row, without the header.

The four, briefly

WeasyPrint

import weasyprint
weasyprint.HTML(filename="invoice.html").write_pdf("invoice.pdf")

A layout engine written for print, with no browser. It handled everything in our test, including CSS grid and the page-number margin box. Two practical notes: it needs Pango from the system (libpango-1.0-0, libpangoft2-1.0-0 on Debian and Ubuntu), and it picks fonts through fontconfig — in our image the generic sans-serif resolved to a CJK font, so name your fonts explicitly with @font-face. It was the slowest of the four here. WeasyPrint documentation.

xhtml2pdf

from xhtml2pdf import pisa
with open("invoice.pdf", "wb") as f:
    pisa.CreatePDF(open("invoice.html").read(), dest=f)

Pure Python on top of ReportLab, fast and easy to install, but its CSS is old: no grid, no border-collapse, no page-margin boxes. Fine for simple, table-based documents you write for it; a poor fit for HTML designed for browsers.

pdfkit (wkhtmltopdf)

import pdfkit
pdfkit.from_file("invoice.html", "invoice.pdf", options={"enable-local-file-access": None})

pdfkit is a thin wrapper around the wkhtmltopdf command, which renders with an old Qt WebKit. The project's GitHub repository has been archived since 2022; in our test it missed grid, header repetition and page numbers, and shrank the page. Many older tutorials still recommend it — for new projects, choose one of the others.

Playwright (Chromium)

from playwright.sync_api import sync_playwright
with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("file:///path/to/invoice.html")
    page.pdf(path="invoice.pdf", format="A4", print_background=True, prefer_css_page_size=True)
    browser.close()

A real browser, so CSS behaves exactly as in Chrome — grid, flexbox, web fonts — and current Chromium also prints @page margin boxes: our "Page 1 of 2" came out. It was the fastest per document once the browser was running; the price is a browser to download and keep updated, and the memory of a Chrome process. print_background=True is needed for coloured backgrounds; Playwright's page.pdf lists the options.

The CSS that made our test document print well

Whatever the library, a few rules decide how a multi-page document breaks. These were in our test file, and WeasyPrint and Playwright honoured all of them:

@page { size: A4; margin: 18mm 16mm 22mm; }
@page { @bottom-center { content: "Page " counter(page) " of " counter(pages); } }
thead { display: table-header-group; }   /* repeat the header row on every page */
tr { break-inside: avoid; }               /* never split a row across pages */

Add break-before: page to an element that must start a new page (a terms section, an appendix). Set the page size in CSS rather than in each library's options, and pass prefer_css_page_size=True to Playwright, so every tool uses the same size.

Which one to use

  • Modern CSS, designed in a browser: Playwright. What you see in Chrome's print preview is what you get.
  • No browser allowed, print-quality layout: WeasyPrint. Best CSS paged-media support of the non-browser options.
  • Simple tables, minimal dependencies: xhtml2pdf, if you write the HTML for it.
  • wkhtmltopdf: only to keep an old system running.

Whichever you use, test with your longest document: page breaks inside table rows, headers on following pages and fonts are where the libraries part ways.

One-page documents without running anything

If the PDF fits on one page — a receipt, a certificate, a ticket, a short invoice — Shareglint renders an HTML template to a one-page vector PDF from a single HTTP request, in Chromium, with no library or browser to install:

import requests

r = requests.post(
    "https://shareglint.com/v1/images?wait=true",
    headers={"Authorization": "Bearer sk_live_..."},
    json={"template": "invoice-a4", "format": "pdf", "modifications": {"number": "2026-0412", "total": "€1,200.00"}},
    timeout=60,
)
pdf_url = r.json()["url"]

Here invoice-a4 is a template you save once in your account (an HTML invoice template to start from). The limit is real: our PDFs are always one page, the template's size, with no page breaks, repeated headers or page numbers. Our 45-row test invoice is exactly the kind of document that needs one of the libraries above; PDF invoices explains where the line is.

FAQ

What is the best library to convert HTML to PDF in Python?

For modern CSS, Playwright (a real Chromium) or WeasyPrint. In our test both laid out CSS grid, repeated the table header on page 2 and printed "Page 1 of 2" from CSS. xhtml2pdf ignored grid, and wkhtmltopdf — whose project is archived — ignored grid, shrank the page and did not repeat the header.

Can I convert HTML to PDF in Python without a browser?

Yes: WeasyPrint and xhtml2pdf are pure Python plus system libraries, with no browser. WeasyPrint supports much more CSS, including @page rules and page-margin boxes; xhtml2pdf is lighter and older, and printed warnings for the CSS it does not implement.

Is wkhtmltopdf still maintained?

No. Its GitHub repository is archived (last push 2022-11-22), and it uses an old Qt WebKit engine: in our test it ignored CSS grid and scaled the content down by default. pdfkit is only a Python wrapper around it.

How do I add page numbers to a PDF from HTML?

With CSS: @page { @bottom-center { content: "Page " counter(page) " of " counter(pages); } }. In our test WeasyPrint and Playwright (Chromium 156) printed it; xhtml2pdf and wkhtmltopdf did not.