How to generate PDFs in Django (2026)
Django already gives you the hard half of PDF generation: a template engine that turns a model into HTML. This guide shows how to finish the job with WeasyPrint, the most popular in-process option, explains its limits, and then shows the same view calling PDFOven with requests.
Option 1: WeasyPrint
WeasyPrint is a Python library with its own layout engine built for print. It understands paged media CSS well: @page rules, page counters, running headers and footers, and page breaks.
pip install weasyprint
WeasyPrint depends on Pango for text layout. On Debian or Ubuntu, install the system packages too:
apt-get install -y libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0
A view that renders a template and returns a download:
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, pk):
invoice = get_object_or_404(Invoice, pk=pk, owner=request.user)
html = render_to_string("invoices/pdf.html", {"invoice": invoice}, request=request)
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.number}.pdf"'
return response
The base_url matters: without it, relative URLs from {% static %} cannot be resolved and your stylesheet and logo will be missing. Setting it to your site URL makes WeasyPrint fetch those assets over HTTP from your own server. If you would rather read them from disk, pass a file:// base URL pointing at STATIC_ROOT, or supply a custom url_fetcher.
Page numbers come from CSS, which is one of WeasyPrint's strengths:
@page {
size: A4;
margin: 15mm;
@bottom-center { content: "Page " counter(page) " of " counter(pages); font-size: 9px; }
}
What to know:
- WeasyPrint does not run JavaScript. Charts rendered client-side will not appear; render them server-side as SVG instead.
- Flexbox and grid support is partial compared with a browser. A template built with a CSS framework for the screen often needs a separate print stylesheet.
- Pango and its dependencies must be present in every image and on every server. Slim and Alpine base images need extra packages, and minor version differences can change line breaking.
- Rendering is CPU-bound and runs inside your web worker. A long report can tie up a Gunicorn worker for seconds; move large documents to Celery.
- If templates include user-supplied URLs, restrict the
url_fetcherso the renderer cannot be used to reach internal services.
For print-first documents with no JavaScript, WeasyPrint is an excellent tool and keeps everything in-process. The alternative for Chrome fidelity is running Playwright, which brings a browser into your deployment; our Python guide covers that route.
The PDFOven way: one request from the view
PDFOven renders with Chromium, so flexbox, grid, web fonts and JavaScript behave as they do in Chrome, and your Django servers need nothing beyond requests.
pip install requests
import os
import requests
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from .models import Invoice
PDFOVEN_KEY = os.environ["PDFOVEN_KEY"]
def invoice_pdf(request, pk):
invoice = get_object_or_404(Invoice, pk=pk, owner=request.user)
html = render_to_string("invoices/pdf.html", {"invoice": invoice}, request=request)
resp = requests.post(
"https://pdfoven.com/v1/pdf",
headers={"Authorization": f"Bearer {PDFOVEN_KEY}"},
json={
"html": html,
"options": {
"format": "A4",
"margin": "0.5in",
"print_background": True,
"footer_template": (
'<div style="font-size:9px;width:100%;text-align:center">'
'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>'
),
},
},
timeout=60,
)
if resp.status_code != 200:
error = resp.json()["error"]
return HttpResponse(f"PDF render failed: {error['code']}", status=502)
response = HttpResponse(resp.content, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="invoice-{invoice.number}.pdf"'
return response
Use inline instead of attachment in the Content-Disposition header to open the PDF in the browser tab.
One difference from WeasyPrint: PDFOven fetches assets over the public internet, so {% static %} URLs must be absolute and reachable. Either serve static files from a public URL or CDN, or inline your CSS and send it in the css field:
json={"html": html, "css": print_css, "options": {"format": "Letter"}}
If the template draws a chart with JavaScript, add "wait_for_selector": "#chart-ready" and create that element when the chart has finished drawing.
From a Celery task
Rendering in the background keeps your web workers free. Ask for base64 if you want the result in a JSON-friendly form:
import base64
from celery import shared_task
from django.core.files.base import ContentFile
@shared_task(autoretry_for=(requests.ConnectionError, requests.Timeout), retry_backoff=True)
def render_invoice(invoice_id):
invoice = Invoice.objects.get(pk=invoice_id)
html = render_to_string("invoices/pdf.html", {"invoice": invoice})
resp = requests.post(
"https://pdfoven.com/v1/pdf",
headers={"Authorization": f"Bearer {PDFOVEN_KEY}"},
json={"html": html, "response": "base64", "filename": f"invoice-{invoice.number}.pdf"},
timeout=60,
)
resp.raise_for_status()
payload = resp.json()
invoice.pdf.save(f"invoice-{invoice.number}.pdf", ContentFile(base64.b64decode(payload["data"])))
The base64 response is {"data", "content_type", "bytes"}. PDFOven does not store generated files, so saving to your own FileField or object storage is the right place to keep them.
Errors always come back as { "error": { "code", "message" } }. A 429 with code quota_exceeded means the monthly quota is used, so it is not worth retrying automatically.
Choosing
| WeasyPrint | PDFOven | |
|---|---|---|
| Rendering engine | Own print engine | Chromium |
| JavaScript | No | Yes |
| Flexbox / grid | Partial | Yes |
| System dependencies | Pango, fonts | None |
| Runs in-process | Yes | No, HTTP call |
PDFOven's free plan includes 100 renders per month; paid plans are $12, $39 and $99 for 2,000, 15,000 and 60,000 renders. To check how a template renders before wiring it up, paste the HTML into the free HTML to PDF tool.
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