← All guides

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 429 with code quota_exceeded tells 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