WeasyPrint for HTML to PDF in 2026: the complete guide

15 June 2026

WeasyPrint converts HTML and CSS to PDF in pure Python with one call: HTML(string=markup).write_pdf("out.pdf"). It is best-in-class for print-grade output (page numbers, running headers, PDF/A, tagged accessibility), and it does not execute JavaScript, so JS-rendered charts and React-only content need a hybrid path or a different engine.

Key Takeaways

- Current version is WeasyPrint v69, released June 2026. It requires Python 3.10 or newer and Pango 1.44 or newer. The v60 release in 2023 cleaned up the API, which is why half of the older Stack Overflow answers no longer compile.
- The minimal call is one line. HTML(string=html).write_pdf("out.pdf") covers strings, URLs, and files. Pass no target to get bytes back instead of writing to disk.
- WeasyPrint does not run JavaScript. This is the single most-asked WeasyPrint question and the answer is no. For JS-rendered content, pre-render server-side with headless Chrome and hand the static HTML to WeasyPrint, or move to a Chrome-based engine.
- CSS Paged Media is the superpower. @page rules, counter(page) for page numbers, and position: running() for headers and footers produce print-grade pagination that browser-based renderers cannot match.
- Concurrency caps around the low dozens of sustained renders. WeasyPrint is fast per call but not a high-throughput engine. Plan for a queue and workers, or move to a hosted API past that ceiling.

WeasyPrint is the answer to one question and the wrong tool for another, and the difference is whether your HTML needs JavaScript. If your invoice template is server-rendered and your statements are pure HTML and CSS, WeasyPrint produces smaller, cleaner, more accessible PDFs than anything driven by a headless browser. If your dashboard depends on Chart.js or your invoice is built in React with client-side hydration, WeasyPrint renders the empty container and nothing else.

This guide is the version of the answer that I wish ranked at the top of the SERP. Current install on every operating system (yes, including the Windows path nobody documents), the v69 API as it stands today, a working Django and Flask endpoint, the @page section that captures the print-fidelity wins, and the honest hybrid pattern when WeasyPrint cannot get you there alone. If you are picking between Python HTML-to-PDF libraries in general, our broader Python HTML-to-PDF guide covers pdfkit, Pyppeteer, xhtml2pdf, and FPDF2 alongside this one. This page goes deep on WeasyPrint.

What is WeasyPrint?

WeasyPrint is a pure-Python, BSD-licensed library that converts HTML and CSS to PDF using its own layout engine. It draws with Cairo, lays out text with Pango, and implements CSS Paged Media for print-grade output. It does not embed a browser, it does not run JavaScript, and it produces vector PDFs with selectable text, tagged accessibility, and PDF/A support out of the box.

Current version is v69.0 as of June 2026, with active maintenance under the Kozea/WeasyPrint and courtbouillon umbrella. Minimum Python is 3.10. Minimum Pango is 1.44.

Install WeasyPrint on every OS (including Windows)

WeasyPrint is pure Python, but it shells out to system libraries (Pango, HarfBuzz, Cairo, Fontconfig) for text and drawing. The install pain is almost always those libraries, not the Python package.

Linux (Debian, Ubuntu)

sudo apt update
sudo apt install python3-pip python3-cffi libpango-1.0-0 libpangoft2-1.0-0
pip install weasyprint

That is the whole story on a modern Ubuntu or Debian. Pango pulls HarfBuzz and Fontconfig in as transitive dependencies.

macOS

brew install pango
pip install weasyprint

Homebrew handles the rest. If you see "library not loaded" errors after a system update, run brew reinstall pango and the linker paths come back.

Windows (the section nobody writes properly)

Windows is where most WeasyPrint installs go sideways. The library needs GTK runtime DLLs on the path, and pip install weasyprint does not put them there.

The clean sequence:

  1. Install MSYS2 from msys2.org.
  2. In the MSYS2 shell, install GTK: pacman -S mingw-w64-x86_64-pango.
  3. Add the GTK bin directory to your system PATH, or set the environment variable WeasyPrint reads:

powershell setx WEASYPRINT_DLL_DIRECTORIES "C:\msys64\mingw64\bin"

  1. Open a new shell so the env var loads.
  2. pip install weasyprint.
  3. python -c "from weasyprint import HTML; HTML(string='<p>hi</p>').write_pdf('hi.pdf')".

If step 6 throws cannot load library libpango-1.0-0.dll, your WEASYPRINT_DLL_DIRECTORIES is pointing at the wrong directory. Verify the DLL is actually at that path with dir "C:\msys64\mingw64\bin\libpango*".

Docker (use Debian-slim, not Alpine)

This is the deploy gotcha. Alpine images use musl libc, and the Pango wheels assume glibc. You will spend an afternoon chasing Error loading shared library before you concede and switch base images.

FROM python:3.12-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
    libpango-1.0-0 libpangoft2-1.0-0 fonts-liberation \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "render.py"]

fonts-liberation is the cheapest way to get a working font set inside the container. Without it, your PDFs render text as empty rectangles, and the error message will not mention fonts.

Convert HTML to PDF with WeasyPrint (the v69 API)

WeasyPrint cleaned up its API in v60. If you are reading a tutorial that calls HTML(file_obj=...) and then a separate .render().write_pdf(), it is dated. The current pattern is one call.

From an HTML string

from weasyprint import HTML

html = "<h1>Invoice #1041</h1><p>Total: $4,800.00</p>"
HTML(string=html).write_pdf("invoice-1041.pdf")

Three lines, full PDF, done.

From a URL

from weasyprint import HTML

HTML("https://example.com/invoice/1041").write_pdf("invoice-1041.pdf")

WeasyPrint fetches the page and renders it. Set base_url if your HTML uses relative paths to local assets you want resolved against a different root.

From a file

from weasyprint import HTML

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

Get bytes back instead of writing to disk

from weasyprint import HTML

pdf_bytes = HTML(string=html).write_pdf()  # No target = bytes returned
# Hand pdf_bytes to S3, attach to an email, or return from a web handler.

This is the common pattern in serverless functions where the filesystem is read-only or short-lived.

Pass external CSS

from weasyprint import HTML, CSS

HTML(string=html).write_pdf(
    "invoice.pdf",
    stylesheets=[CSS(filename="invoice.css")],
)

You can also pass CSS(string="...") to inline CSS or CSS(url="...") to fetch it.

Page numbers, running headers, and footers

This is the section that justifies picking WeasyPrint over a browser-driven renderer. CSS Paged Media is a real W3C spec, and WeasyPrint implements it more completely than headless Chrome does.

@page rules: the entry point

@page {
  size: A4;
  margin: 2cm;

  @top-center {
    content: "Quarterly Report";
    font-weight: bold;
  }

  @bottom-right {
    content: "Page " counter(page) " of " counter(pages);
  }
}

That CSS gives you a margin, a centered header on every page, and "Page 3 of 12" in the bottom-right corner. The counter(page) and counter(pages) references are part of the spec, not a WeasyPrint extension.

Running headers that pull from the content

For a header that shows the current section title, use position: running():

h2 {
  position: running(section-title);
}

@page {
  @top-left {
    content: element(section-title);
  }
}

Every <h2> is moved out of the normal flow and into a slot that the header element pulls from. As the reader moves through the document, the header updates.

Per-section page rules

Different pages for different sections (a landscape cover page, then portrait body):

.cover { page: cover; }
.body  { page: body; }

@page cover { size: A4 landscape; margin: 0; }
@page body  { size: A4 portrait;  margin: 2cm; }

Page breaks

break-before: page, break-after: page, and break-inside: avoid work the same as they do in print CSS everywhere. Use them on the elements you do not want split across pages, such as invoice line-item tables.

CSS support and the flexbox myth

WeasyPrint's flexbox support is the kind of thing the internet got wrong in 2019 and has been quoting back ever since. Current versions support both flexbox and CSS Grid. The "WeasyPrint doesn't do flex" claim still floats around Stack Overflow and outdated blog posts. It is not accurate on v69.

What works today:

  • Flexbox and Grid: yes, both.
  • Media queries: yes, including @media print which is honored automatically.
  • Webfonts via @font-face: yes. Self-host the file; remote loading over a slow network can timeout mid-render.
  • CSS Paged Media: yes, broadly. WeasyPrint passes Acid2.
  • Modern color functions: check the WeasyPrint docs for the version you are on. Pango and Cairo coverage trails Chromium by a release or two, so freshly-shipped color functions can land in WeasyPrint a few months later.

What does not work:

  • JavaScript: not at all. This is by design. Chart.js, D3, React client-side rendering, any AJAX-loaded content. WeasyPrint sees what your server sends; that is the whole document.
  • A handful of CSS 2.1 edge cases: visibility: collapse on tables, full system colors and fonts. None of these are common in real templates.

WeasyPrint with Django and Flask

Most production WeasyPrint code lives behind a web view. The pattern is the same in any Python framework: render the template to a string, hand the string to WeasyPrint, return the bytes with Content-Type: application/pdf.

Django: a complete view

# views.py
from django.http import HttpResponse
from django.template.loader import render_to_string
from weasyprint import HTML

def invoice_pdf(request, invoice_id):
    invoice = Invoice.objects.get(pk=invoice_id)
    html = render_to_string("invoices/show.html", {"invoice": invoice})
    pdf = HTML(string=html, base_url=request.build_absolute_uri()).write_pdf()

    response = HttpResponse(pdf, content_type="application/pdf")
    response["Content-Disposition"] = f'attachment; filename="invoice-{invoice_id}.pdf"'
    return response

base_url=request.build_absolute_uri() is the line most tutorials skip. It tells WeasyPrint how to resolve relative paths in your template (logos, stylesheets, anything src="/static/...") against your live site. Skip it and your logo will not appear.

For a one-line helper, django-weasyprint provides a class-based PDF response. It is a thin wrapper over the above and worth it once you have more than three PDF views.

Flask: minimal example

# app.py
from flask import Flask, render_template, Response
from weasyprint import HTML

app = Flask(__name__)

@app.route("/invoice/<int:invoice_id>.pdf")
def invoice_pdf(invoice_id):
    html = render_template("invoice.html", invoice_id=invoice_id)
    pdf = HTML(string=html, base_url=app.config["BASE_URL"]).write_pdf()
    return Response(pdf, mimetype="application/pdf")

The Flask-WeasyPrint package wraps URL handling against the Flask test client, which is occasionally useful for in-app renders. For most cases, the direct call above is enough.

When WeasyPrint is the wrong tool

Three walls. Hit any one and WeasyPrint is no longer the right choice.

Wall 1: the HTML depends on JavaScript

If your invoice template embeds a Recharts component, or your dashboard pulls data with useEffect and renders on the client, WeasyPrint sees the empty <div id="root"></div> your server sent. It cannot run the JS that fills it.

Two options. The first is the hybrid pattern: render the page in a real browser server-side, capture the final HTML, then hand that static HTML to WeasyPrint for its paged-media output. The second is to move the whole render to a Chrome-based engine and accept that you give up some of the paged-media polish.

Wall 2: sustained high concurrency

WeasyPrint is fast per render but not a high-throughput engine. The practical ceiling is in the low dozens of concurrent renders before throughput stops scaling linearly. For end-of-month statement runs that have to render 50,000 invoices in an hour, you need a queue and workers, not a single Python process.

Wall 3: cross-platform reliability without per-OS dependency work

If your team is shipping a Python service that has to run on macOS, Linux, and Windows developer machines plus a Linux production server plus a Lambda function, you are now maintaining the Pango install on five environments. A hosted API trades that work for an HTTP call.

WeasyPrint vs pdfkit vs Chrome (the decision table)

A quick honest comparison for choosing among Python HTML-to-PDF options:

Engine JS execution Modern CSS License PDF/A Concurrency Best for
WeasyPrint No Yes (flex, grid) BSD Yes Low Print-grade paged output, invoices, statements
pdfkit (wkhtmltopdf) Limited (QtWebKit, dated) Partial LGPL No Medium Legacy templates, fast rendering
Headless Chrome (Pyppeteer or hosted) Full Full Mixed No High (hosted) Dashboards, charts, JS-rendered content

If you want the deeper version of this comparison with full code for each library, that lives in our Python HTML-to-PDF guide. For wkhtmltopdf specifically, the wkhtmltopdf tutorial covers the engine on its own.

Use a hosted engine when WeasyPrint cannot reach

When you hit Wall 1, 2, or 3, the cleanest path is a hosted HTML-to-PDF API. Transformy gives you two engines behind one request: the Chrome engine for JS-heavy pages, and a maintained wkhtmltopdf engine for legacy templates. Same auth, same Python call shape.

import os
import requests

response = requests.post(
    "https://api.transformy.io/v1/render",
    headers={
        "Authorization": f"Bearer {os.environ['TRANSFORMY_API_KEY']}",
        "Content-Type": "application/json",
    },
    json={
        "url": "https://app.example.com/dashboards/42",
        "engine": "chrome",
        "wait_for": ".dashboard-loaded",
    },
)
pdf_url = response.json()["pdf_url"]

The wait_for parameter is the WeasyPrint-can't-do-this wedge. The Chrome engine renders the page, holds the snapshot until your selector appears in the DOM, then returns a URL to the PDF. The same template can hit engine: "wkhtmltopdf" when you want the lighter, faster path for static documents.

If WeasyPrint renders your page reliably and you do not need JS or high concurrency, keep WeasyPrint. It is genuinely the better tool for that job. Render your first PDF on the Transformy free tier only when WeasyPrint cannot reach the page you need.

FAQ: WeasyPrint HTML to PDF

What is WeasyPrint used for?

WeasyPrint is a pure-Python library that converts HTML and CSS into print-grade PDFs. It is the standard answer for invoices, statements, reports, and any document where page numbers, running headers and footers, and CSS Paged Media matter more than JavaScript rendering.

Does WeasyPrint support JavaScript?

No. WeasyPrint does not execute JavaScript, by design. For HTML that depends on client-side rendering (Chart.js, D3, React with useEffect), either pre-render the page server-side with headless Chrome and hand the static HTML to WeasyPrint, or render the whole thing in a Chrome-based engine.

How do I add page numbers in WeasyPrint?

Use the counter(page) and counter(pages) CSS counters inside an @page rule, like @page { @bottom-right { content: "Page " counter(page) " of " counter(pages); } }. WeasyPrint substitutes the current page number and the total automatically. No Python code is required; it is pure CSS.

How do I install WeasyPrint on Windows?

Install MSYS2, then run pacman -S mingw-w64-x86_64-pango inside the MSYS2 shell. Set the environment variable WEASYPRINT_DLL_DIRECTORIES to point at the GTK bin directory (commonly C:\msys64\mingw64\bin). Open a new shell and run pip install weasyprint. The DLL path is the step most tutorials skip.

Does WeasyPrint render flexbox and grid?

Yes, on current versions. The "WeasyPrint doesn't support flex" claim is dated and refers to releases from before 2022. Flexbox and CSS Grid both work on v69. If a layout is broken, the cause is almost always Pango font handling or a CSS Paged Media interaction, not flex itself.

WeasyPrint vs wkhtmltopdf: which should I use?

Use WeasyPrint when your HTML is server-rendered and you want print-grade output (page numbers, running headers, PDF/A). Use wkhtmltopdf when you have a legacy template that already targets it, or you need the slightly faster per-render throughput on stable templates. Reach for Chrome when the page depends on JavaScript.

Conclusion: WeasyPrint is the right answer for the printable web

WeasyPrint converts HTML to PDF in pure Python, with the best CSS Paged Media implementation in the Python ecosystem, BSD licensing, vector output, and tagged accessibility. The minimal call is one line, the Django and Flask integration is straightforward, and the @page rules give you print-grade layout that headless browsers struggle to match.

It has three real limits. No JavaScript execution, a concurrency ceiling in the low dozens, and a Windows install sequence that is not in the official quickstart. None of those are fatal, all three have known workarounds, and only the JavaScript wall is unavoidable. If your HTML is server-rendered and your templates are stable, WeasyPrint is the smallest tool that produces the PDF you need.

A practical path for picking. (1) Try HTML(string=html).write_pdf("out.pdf") first. (2) Add @page rules and page counters when you need real pagination. (3) Drop in base_url when your assets stop loading. (4) Move to the hybrid pre-render pattern, or a Chrome-based engine, when the page needs JavaScript. (5) Move to a hosted API when the concurrency ceiling or the cross-platform install matrix becomes the bottleneck.

If you hit Wall 1, 2, or 3, render your first PDF on the Transformy free tier with engine: "chrome" and wait_for pointing at the element that signals the JS is done. Same Python request shape you already have. No Chromium to install, no zombie processes to babysit, no DLL paths to discover.