weasyprint
WeasyPrint turns HTML and CSS into PDF with a layout engine written in Python instead of a browser. There is no Chromium, no V8 and no JavaScript anywhere in the pipeline: it parses markup with tinyhtml5, resolves CSS with tinycss2 and cssselect2, shapes text through Pango, and serialises the PDF with pydyf. Because the engine was built for paged media rather than screens, the parts of CSS that browsers treat as an afterthought are the parts it takes seriously: @page size and margins, margin boxes for running headers and footers, page and pages counters, named strings, break-before and break-inside, orphans and widows. The trade is the exact inverse of headless Chrome. You give up scripting and pixel-for-pixel browser fidelity, and you get a small single-process renderer whose output is a pure function of your HTML, your CSS and the fonts on the machine.
For server-side HTML to PDF where the markup is yours and static, WeasyPrint is the least painful option in Python and the only one that treats paged CSS as a first-class target. Budget the first day for system libraries and fonts, and the second for turning screen CSS into print CSS.
Use it if
- You generate invoices, statements, tickets or reports from server-side templates and care about page breaks, repeated table headers and page numbering more than about matching a Chrome screenshot
- You cannot ship a headless browser: no Chromium in the image, a container memory budget in the hundreds of megabytes, or a platform that will not let you spawn a sandboxed subprocess
- You need PDF/A or PDF/UA output, tagged PDFs for screen readers, embedded file attachments or XMP metadata, all of which are keyword options on write_pdf rather than a separate post-processing step
- You want the render to be reproducible in CI: same HTML, same CSS, same fonts, same bytes, with no browser version drifting underneath you
- Your page needs JavaScript to exist. WeasyPrint never executes a script tag, so anything painted by React, Vue, Chart.js or a map widget comes out as empty space with no error. Pre-render the markup on the server, or use Playwright and pay for the browser.
- Pango 1.44 or newer must already be on the machine. pip installs the Python dependencies and stops there, so a slim Debian image, an Alpine container or a Windows box fails at import until you install the GObject, Pango, HarfBuzz and fontconfig shared libraries yourself.
- Layout runs in Python on a single core. Documents that run to hundreds of pages are CPU bound, and rendering one inside a web request will hold a worker for the duration. Queue those jobs.
- Your CSS was written for a browser. Screen layouts usually need reworking for print, and the only reliable way to find out what moved is to page through the output, because the engine warns about missing resources but not about layout you did not expect.
- The job is editing PDFs rather than producing them. Merging, splitting, stamping or filling an existing AcroForm is pypdf territory; WeasyPrint only writes new documents.
- You need output bytes to survive upgrades. Five major versions shipped between April 2025 and June 2026 (65.1 through 69.0), rendering changes with them, and pinned visual snapshot tests will break every time you bump.
Setup reality
pip install weasyprint pulls pydyf, cffi, tinyhtml5, tinycss2, cssselect2, Pyphen, Pillow and fontTools with the woff extra, and none of that is the hard part. The hard part is Pango 1.44 or newer plus HarfBuzz, GObject and fontconfig, which are system shared libraries that pip does not touch. On Debian and Ubuntu that means apt install libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0; on Alpine you need the so:libpango-1.0.so.0 style package names; on Windows you install the GTK runtime. Run weasyprint --info first in any new image, because it prints the Pango version it found and fails loudly instead of producing a broken PDF. The second surprise is fonts: a slim container ships with none, so text falls back to whatever fontconfig can find, which may be nothing at all. Install a font package or embed WOFF files through @font-face with a shared FontConfiguration object. Python 3.10 or newer.
Patterns
Render an HTML string to PDF bytesrender-string-to-bytes
from weasyprint import HTML
pdf_bytes = HTML(
string="<h1>Invoice 42</h1>",
base_url="https://example.com/",
).write_pdf()Leaving target unset returns the PDF as bytes instead of writing a file. Always pass base_url when the input is a string: without it every relative img src and stylesheet link resolves against nothing, and the resource is skipped with only a log line to show for it.
Add stylesheets from outside the documentattach-stylesheets
from weasyprint import CSS, HTML
HTML(filename="invoice.html").write_pdf(
"invoice.pdf",
stylesheets=[
CSS(filename="print.css"),
CSS(string="@page { size: A4; margin: 18mm; }"),
],
)These are applied at user origin, not author origin, so any rule the document itself links wins a specificity tie against them. If a stylesheet you pass here appears to do nothing, that is why; mark the declaration !important or move it into the document.
Page size, margins and running headerspaged-media-css
@page {
size: A4;
margin: 20mm 15mm 22mm 15mm;
@top-center { content: string(doc-title); font-size: 9pt; }
@bottom-right { content: "Page " counter(page) " of " counter(pages); }
}
h1 { string-set: doc-title content(); }counter(pages) is only knowable after layout, which is why you cannot compute a total in your template. string-set plus content() is the mechanism for a header that tracks the current section, and there is no scripting escape hatch if you need something the margin boxes cannot express.
Keep rows together and repeat table headerscontrol-page-breaks
.invoice-line { break-inside: avoid; }
h2 { break-after: avoid; }
.appendix { break-before: page; }
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
p { orphans: 3; widows: 3; }display: table-header-group is the only switch that repeats a table header on every page. break-before: page on an element that already sits at the top of a page still emits a blank page, so guard it with a :not(:first-child) selector.
Embed a font with @font-faceembed-webfonts
from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration
font_config = FontConfiguration()
css = CSS(string="""
@font-face {
font-family: Inter;
src: url(file:///app/fonts/Inter.woff2) format("woff2");
}
body { font-family: Inter, sans-serif; }
""", font_config=font_config)
HTML(filename="report.html").write_pdf(
"report.pdf", stylesheets=[css], font_config=font_config)The same FontConfiguration instance has to reach every CSS object and write_pdf itself. Pass it to one and not the other and the @font-face rule is parsed and then discarded, with no error and a silent fallback font in the PDF.
Lock down what the renderer is allowed to fetchrestrict-url-fetching
from weasyprint import HTML
from weasyprint.urls import URLFetcher
class LocalOnly(URLFetcher):
def fetch(self, url, headers=None):
if not url.startswith(("file:///app/assets/", "data:")):
raise ValueError(f"blocked: {url}")
return super().fetch(url, headers)
HTML(string=user_html, base_url="file:///app/assets/",
url_fetcher=LocalOnly(timeout=5)).write_pdf("out.pdf")Rendering user-supplied HTML with the default fetcher is a server-side request forgery hole: an img tag pointed at a cloud metadata endpoint gets fetched by your process. URLFetcher also accepts allowed_protocols and fail_on_errors directly if a protocol allowlist is enough. The old module-level default_url_fetcher is deprecated in favour of this class.
Produce PDF/A or a tagged accessible PDFarchive-pdf-variant
from weasyprint import HTML
HTML(filename="report.html").write_pdf(
"report.pdf",
pdf_variant="pdf/a-3b", # or pdf/ua-1, pdf/a-1a, pdf/x-4 ...
custom_metadata=True,
)Choosing a variant overrides related options for you: the pdf/a-*a conformance levels force pdf_tags on, and every PDF/A profile sets pdf_version, pdf_identifier and an sRGB output intent. Setting pdf_variant does not by itself stop font subsetting, so pass full_fonts=True if your validator demands complete embedding.
Concatenate several rendered documentsmerge-rendered-documents
from weasyprint import HTML
docs = [HTML(filename=p).render() for p in ("cover.html", "body.html")]
pages = [page for doc in docs for page in doc.pages]
docs[0].copy(pages).write_pdf("merged.pdf")render() stops before serialisation and hands you a Document whose .pages list can be sliced, reordered or concatenated. Page counters were resolved per source document, so numbering restarts at each boundary; if you need one continuous sequence, render a single document instead.
Cut the file size of an image-heavy documentshrink-output
from weasyprint import HTML
HTML(filename="catalogue.html").write_pdf(
"catalogue.pdf",
optimize_images=True,
jpeg_quality=80,
dpi=150,
full_fonts=False,
)optimize_images re-encodes rasters and dpi downsamples them to that resolution, and both add render time on a document with many photos. full_fonts=False subsets embedded fonts and is already the default, so if your PDFs are large and text-heavy the fonts are probably not the cause.
Serve a PDF from a Django viewdjango-pdf-response
from django.http import HttpResponse
from django.template.loader import render_to_string
from weasyprint import HTML
def invoice_pdf(request, pk):
html = render_to_string("invoice.html", {"invoice": load(pk)})
pdf = HTML(
string=html,
base_url=request.build_absolute_uri("/"),
).write_pdf()
return HttpResponse(pdf, content_type="application/pdf")build_absolute_uri is what makes {% static %} URLs resolve; drop it and the logo and stylesheet vanish without an exception. This also runs layout inside the request, so move anything longer than a couple of pages into Celery or RQ before it starts eating gunicorn workers.
Find out why an image or font did not renderdiagnose-missing-resources
import logging
from weasyprint import HTML
logging.basicConfig(level=logging.INFO)
logging.getLogger("weasyprint").setLevel(logging.DEBUG)
logging.getLogger("weasyprint.progress").setLevel(logging.INFO)
HTML(filename="report.html").write_pdf("report.pdf")WeasyPrint installs a NullHandler and says nothing by default, so a stylesheet that 404s or a local font it cannot open produces blank output and a clean exit code. The weasyprint logger carries resource and CSS syntax problems; weasyprint.progress carries the step-by-step rendering trace.
Render from the command line and check the installcli-usage
weasyprint --info
weasyprint --stylesheet print.css --media-type print \
--presentational-hints invoice.html invoice.pdf
weasyprint --pdf-variant pdf/a-3b --optimize-images \
--jpeg-quality 80 --dpi 150 report.html report.pdfweasyprint --info is the first command to run in a new image because it reports the Pango version it actually found. --presentational-hints turns on legacy HTML attributes such as table border and bgcolor, which is off by default and is usually what people are missing when an old email template renders unstyled.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| playwright | PyPI | The document only exists after JavaScript runs, or you need the output to match what Chrome shows on screen |
| reportlab | PyPI | You would rather draw the page programmatically in Python than express it as HTML and CSS |
| pypdf | PyPI | The task is merging, splitting, stamping or reading existing PDFs rather than generating new ones |