To generate a PDF from an HTML template in Django with WeasyPrint, render the template to an HTML string with render_to_string(), pass that string to weasyprint.HTML(string=html, base_url=...), call .write_pdf() to get the PDF bytes, and return them in an HttpResponse with content_type="application/pdf". WeasyPrint is a pure-Python engine that turns HTML and CSS — including the CSS Paged Media features for page size, margins, running headers and page numbers — into print-quality PDFs, which makes it ideal for invoices, receipts, statements and reports.
WeasyPrint is the right choice when your document is server-rendered HTML that does not depend on client-side JavaScript: it has excellent CSS support, ships no browser, and exposes a clean Python API. If your page builds its content with JavaScript (charts drawn by JS, single-page dashboards), reach for a headless-Chrome approach instead — see the comparison table below.
This guide is current for Django 5.x and WeasyPrint v60+ (which requires Python 3.9+; Python 2 support was dropped years ago).
Install WeasyPrint and its system dependencies
WeasyPrint is published on PyPI, so the Python package installs with pip:
pip install weasyprint
# Confirm the install and see what native libraries it found
python -m weasyprint --infoWeasyPrint is not pure Python all the way down — it lays out text through Pango, so the native Pango libraries (plus GLib, HarfBuzz and Fontconfig) must be present on the machine. Modern WeasyPrint (v53 and later, which covers every current v60+ release) no longer needs Cairo or GDK-PixBuf — older tutorials that tell you to install libcairo2 and libgdk-pixbuf2.0-0 are out of date. Install the libraries with your OS package manager:
# Debian / Ubuntu (20.04+)
sudo apt install libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0
# Fedora
sudo dnf install pango
# macOS (Homebrew)
brew install pango
# Then, inside your virtualenv:
pip install weasyprintThe #1 deployment gotcha: native libraries in your container
pip install weasyprint installs the Python wheel but not the native Pango libraries. This is why a project that works on your laptop throws OSError: cannot load library 'libpango-1.0-0' (or a libgobject error) inside Docker or on a fresh server. Add the system packages to your image:
FROM python:3.12-slim
# WeasyPrint needs Pango (and friends) at runtime — pip alone is not enough.
RUN apt-get update && apt-get install -y --no-install-recommends \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz-subset0 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .Render a Django template to a PDF response
The core pattern has three steps: render a template to a string, convert that string to PDF bytes, and return the bytes. First, an ordinary Django template — nothing WeasyPrint-specific:
{# templates/invoices/invoice.html #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/static/css/invoice.css">
</head>
<body>
<h1>Invoice #{{ invoice.number }}</h1>
<img src="/static/img/logo.png" alt="Company logo">
<p>Billed to: {{ invoice.customer_name }}</p>
<table class="items">
{% for item in invoice.items.all %}
<tr class="invoice-item">
<td>{{ item.description }}</td>
<td>{{ item.amount }}</td>
</tr>
{% endfor %}
</table>
</body>
</html>Now the view. Use render_to_string() so you hand WeasyPrint real, rendered HTML, and pass base_url so relative URLs for CSS, images and fonts can be resolved (more on that below):
# views.py
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from weasyprint import HTML
from .models import Invoice
def invoice_pdf(request, invoice_id):
invoice = get_object_or_404(Invoice, pk=invoice_id)
# 1. Render the Django template to an HTML string.
html_string = render_to_string(
"invoices/invoice.html",
{"invoice": invoice},
)
# 2. Convert the HTML string to PDF bytes. With target=None,
# write_pdf() returns the PDF as bytes instead of writing a file.
pdf_bytes = HTML(
string=html_string,
base_url=request.build_absolute_uri("/"),
).write_pdf()
# 3. Return the bytes as a PDF response.
response = HttpResponse(pdf_bytes, content_type="application/pdf")
response["Content-Disposition"] = 'inline; filename="invoice.pdf"'
return responseOpen in the browser vs force a download
The Content-Disposition header decides what the browser does. Use inline to display the PDF in the browser's built-in viewer, or attachment to force a download. For a file already on disk, stream it with FileResponse:
# Force a download instead of inline display
response["Content-Disposition"] = 'attachment; filename="invoice.pdf"'
# Or stream a previously generated file straight from disk
from django.http import FileResponse
return FileResponse(
open("/path/to/invoice.pdf", "rb"),
as_attachment=True,
filename="invoice.pdf",
content_type="application/pdf",
)Style PDFs for print with @page CSS
Screen CSS and print CSS are different worlds. WeasyPrint implements the CSS Paged Media and Generated Content for Paged Media specs, so page size, margins, running headers/footers and page numbers are all controlled with CSS — there is no special Python API for them. Define an @page rule with margin boxes:
/* static/css/invoice.css — print styles for WeasyPrint */
@page {
size: A4; /* or Letter */
margin: 2cm 1.5cm;
@bottom-center {
content: "Page " counter(page) " of " counter(pages);
font-size: 9pt;
color: #555;
}
@top-right {
content: string(doc-title); /* running header, see below */
}
}
/* Promote the H1 text into the running header */
h1 { string-set: doc-title content(); }
/* Control page breaks */
.invoice-item { break-inside: avoid; } /* keep a row together */
.terms { break-before: page; } /* start on a fresh page */
/* Embed a font so the PDF looks identical everywhere */
@font-face {
font-family: "Inter";
src: url("/static/fonts/Inter.ttf");
}
body { font-family: "Inter", sans-serif; }A few things to notice: counter(page) and counter(pages) give you Page X of Y; string-set captures text from the document and string() echoes it into a margin box as a running header; break-inside: avoid and break-before: page control pagination; and @font-face embeds fonts so output is reproducible. The one catch is that the font file (and every url(...)) must be resolvable — which is exactly what base_url handles next.
Load static files, images and fonts with base_url
The most common WeasyPrint frustration is a PDF where the logo, stylesheet or fonts are missing. WeasyPrint knows nothing about your Django project — it only sees an HTML string. Relative URLs like <img src="/static/logo.png"> or url(...) inside CSS need a base to resolve against. Pass base_url so WeasyPrint can fetch them:
from django.contrib.staticfiles import finders
from weasyprint import HTML, CSS
# Strategy A — HTTP base_url: WeasyPrint fetches assets over HTTP,
# exactly like a browser. The static URLs must be reachable.
pdf_bytes = HTML(
string=html_string,
base_url=request.build_absolute_uri("/"),
).write_pdf()
# Strategy B — filesystem paths: skip HTTP and point WeasyPrint at
# files on disk via staticfiles finders. Works even when static files
# are not publicly served (e.g. during a Celery job with no request).
css_path = finders.find("css/invoice.css")
pdf_bytes = HTML(
string=html_string,
base_url="/",
).write_pdf(stylesheets=[CSS(filename=css_path)])Two reliable strategies:
- HTTP base_url (
request.build_absolute_uri('/')): WeasyPrint downloads assets over HTTP just as a browser would. In production withDEBUG=False, confirm your static files are collected (collectstatic) and actually served by your web server or CDN. - Filesystem paths: avoid HTTP entirely by resolving files on disk with Django's staticfiles
finders(or absolutefile://URLs). This is faster and works in contexts that have no HTTP request, such as a background task.
Save a PDF to a file or attach it to an email
Calling write_pdf() with no target returns the PDF as bytes; pass a path or file object to write it to disk. Those same bytes attach straight to a Django email:
from django.conf import settings
from django.core.mail import EmailMessage
from django.template.loader import render_to_string
from weasyprint import HTML
def email_invoice(invoice):
html_string = render_to_string("invoices/invoice.html", {"invoice": invoice})
pdf_bytes = HTML(string=html_string, base_url=settings.SITE_URL).write_pdf()
# Optionally keep a copy on disk.
with open(f"/tmp/invoice-{invoice.number}.pdf", "wb") as fh:
fh.write(pdf_bytes)
email = EmailMessage(
subject=f"Your invoice #{invoice.number}",
body="Please find your invoice attached.",
to=[invoice.customer_email],
)
email.attach(f"invoice-{invoice.number}.pdf", pdf_bytes, "application/pdf")
email.send()Keep requests fast: generate PDFs in a background task
PDF rendering is CPU-bound — a complex, multi-page document with images and custom fonts can take anywhere from a few hundred milliseconds to several seconds. Generating PDFs inside a web request ties up a worker for that whole time, so for bulk exports, large documents or high concurrency, move the work to a background queue such as Celery and store or email the result:
# tasks.py
from celery import shared_task
from django.conf import settings
from django.core.files.base import ContentFile
from django.template.loader import render_to_string
from weasyprint import HTML
from .models import Invoice
@shared_task
def generate_invoice_pdf(invoice_id):
invoice = Invoice.objects.get(pk=invoice_id)
html_string = render_to_string("invoices/invoice.html", {"invoice": invoice})
pdf_bytes = HTML(string=html_string, base_url=settings.SITE_URL).write_pdf()
# Persist to a FileField (or S3) so repeat downloads never re-render.
invoice.pdf.save(f"invoice-{invoice.number}.pdf", ContentFile(pdf_bytes))
return invoice.pdf.urlGenerate once and cache the file on storage (S3, the database, or a FileField) so repeat downloads serve the cached PDF instead of re-rendering it every time.
WeasyPrint vs other Python HTML-to-PDF tools
WeasyPrint is not the only option. Here is how the common Python PDF approaches compare in 2026:
| Tool | HTML / CSS fidelity | JavaScript | Maintenance | Best for |
|---|---|---|---|---|
| WeasyPrint | Excellent CSS, strong Paged Media | No JS engine | Active | Invoices, reports, statements from server-rendered HTML |
| xhtml2pdf (pisa) | Limited, legacy CSS subset | No | Sporadic | Simple legacy documents |
| wkhtmltopdf | Good, but old WebKit | Yes (dated engine) | Archived / unmaintained | Legacy projects only |
| ReportLab | N/A — no HTML input | No | Active (OSS + commercial) | Programmatic, pixel-precise layouts in Python |
| Headless Chrome / Playwright | Best, modern CSS | Full JS | Active | JS-heavy or charted pages; heavy runtime |
Short version: choose WeasyPrint for invoices, statements and reports rendered from your own HTML/CSS. Reach for a headless-Chrome/Playwright pipeline only when the document genuinely depends on JavaScript — it costs you a full browser in your deployment. Avoid wkhtmltopdf for new work: the project is archived and its bundled WebKit is years out of date. xhtml2pdf supports only a limited CSS subset, and ReportLab is a programmatic drawing canvas, not an HTML converter.
Frequently Asked Questions
Does WeasyPrint run JavaScript when generating a PDF?
No. WeasyPrint renders HTML and CSS only — it has no JavaScript engine. If your page draws content with JavaScript (for example a Chart.js graph or a React dashboard), render that data into static HTML/SVG on the server first, or use a headless-Chrome/Playwright pipeline. For server-rendered Django templates, the lack of a JS engine is a feature: WeasyPrint is lighter and more predictable.
Why are my images and CSS missing in the WeasyPrint PDF?
Almost always a base_url problem. WeasyPrint receives only an HTML string, so relative URLs have nothing to resolve against. Pass base_url=request.build_absolute_uri('/') (or absolute file:// paths) and make sure those assets are actually reachable. In production with DEBUG=False, confirm your static files are collected and served, or point WeasyPrint at files on disk via staticfiles finders.
How do I add page numbers and headers to a Django PDF?
With CSS, not Python. Inside an @page rule, use a margin box such as @bottom-center { content: "Page " counter(page) " of " counter(pages); }. For running headers, capture text with string-set on an element and echo it with string() in a margin box. WeasyPrint implements the CSS Paged Media spec, so this works without any extra library.
What system libraries does WeasyPrint need on a server or in Docker?
Pango and its dependencies (GLib, HarfBuzz, Fontconfig). On Debian/Ubuntu that is libpango-1.0-0, libpangoft2-1.0-0 and libharfbuzz-subset0. pip install weasyprint does not install these, which is the usual cause of cannot load library errors in containers. Note that current WeasyPrint (v53+) no longer needs Cairo or GDK-PixBuf.
How do I return the PDF as a download instead of opening it in the browser?
Set the Content-Disposition header to attachment instead of inline: response["Content-Disposition"] = 'attachment; filename="invoice.pdf"'. With inline, supported browsers open the PDF in their built-in viewer; with attachment, the browser downloads it. To stream an already-generated file from disk, use FileResponse(open(path, "rb"), as_attachment=True, filename=...).
Is WeasyPrint fast enough for high-volume PDF generation?
For occasional, small documents, generating inside a request is fine. WeasyPrint is CPU-bound, so for large multi-page files or many concurrent users, move generation to a background worker (Celery, RQ, Django-Q) and cache the result. Reusing one rendered file for repeat downloads, subsetting fonts (the default) and keeping images appropriately sized all improve throughput.
Can WeasyPrint fill in or edit an existing PDF form?
No — WeasyPrint creates new PDFs from HTML and CSS; it does not edit, merge or fill existing PDF files. For that, combine it with a library like pypdf or pikepdf (to merge or stamp pages) or ReportLab's form tools. A common pattern is to render the body with WeasyPrint and then merge it with a pre-made cover page using pypdf.
Building document-heavy Django apps
WeasyPrint covers the vast majority of invoice, receipt, statement and report use cases in Django with nothing more than the HTML and CSS you already know. Keep the native Pango libraries in your deployment image, always set base_url, lean on @page CSS for layout, and push heavy rendering into a background queue.
At MicroPyramid we have spent 12+ years and 50+ projects building Django applications — including document and PDF generation pipelines for invoices, contracts and reports. If you are planning custom software or scaling an existing Python platform, the patterns above are the same ones we ship to production.