How to return a PDF from a Django view: render your normal Django template to HTML, convert it, and send it back as a download. Three converters, each tested in a real Django 6.1 project on the same invoice template: WeasyPrint 67, Playwright with Chromium, and an HTTP API.
| Converter | Pick it when |
|---|---|
| WeasyPrint Python library, no browser | Invoices, receipts, reports you design yourself. Good print CSS (page numbers, margins), flexbox and grid. No JavaScript. |
| Playwright Headless Chromium | Templates that need JavaScript (charts) or must match Chrome exactly. You install and run a browser next to Django. |
| An API Chromium on someone else's server | Chrome output without a browser on your host: PaaS dynos, small containers, serverless. |
All three start the same way: render_to_string() turns your template and context into an HTML string. Only the conversion line differs.
Save as invoices/templates/invoices/invoice.html. It is an ordinary Django template; the @page rule sets A4 and prints "Page 1 of 1" in the corner. In the views below, get_invoice(pk) stands for your own lookup, such as get_object_or_404(Invoice, pk=pk), returning the context the template uses.
<!doctype html>
<html><head><meta charset="utf-8"><style>
@page { size: A4; margin: 2cm; @bottom-right { content: "Page " counter(page) " of " counter(pages); } }
body { font-family: sans-serif; }
.top { display: flex; justify-content: space-between; }
table { width: 100%; border-collapse: collapse; }
td, th { border-bottom: 1px solid #ddd; padding: 6px; text-align: left; }
</style></head><body>
<div class="top"><h1>Invoice #{{ invoice.number }}</h1><p>Due: {{ invoice.due }}</p></div>
<table><tr><th>Item</th><th>Price</th></tr>
{% for line in invoice.lines %}<tr><td>{{ line.item }}</td><td>€{{ line.price }}</td></tr>{% endfor %}
</table>
</body></html>
pip install weasyprint
On Linux it also needs Pango (apt install weasyprint pulls everything in on Debian/Ubuntu; in a python:3 Docker image, apt-get install -y libpango-1.0-0 libpangoft2-1.0-0). On macOS, brew install weasyprint. Then the view:
from django.http import HttpResponse
from django.template.loader import render_to_string
from weasyprint import HTML
def invoice_pdf(request, pk):
invoice = get_invoice(pk) # e.g. get_object_or_404(Invoice, pk=pk)
html = render_to_string("invoices/invoice.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-{pk}.pdf"'
return response
And the URL, in urls.py:
path("invoices/<int:pk>.pdf", views.invoice_pdf),
Result: a one-page A4 PDF with the title and due date side by side and "Page 1 of 1" at the bottom right, downloaded as invoice-7.pdf. To show it in the browser's PDF viewer instead of downloading, change attachment to inline in the Content-Disposition header.
Images and CSS files. WeasyPrint resolves relative URLs like {% static "logo.png" %} against base_url; the view above points it at your own site, so it fetches them over HTTP. That works, but it's an extra request per asset while your worker is busy. For production, inline small images as data URIs or pass an absolute file:// path to your STATIC_ROOT in the template.
write_pdf() with no argument returns bytes, which EmailMessage.attach takes directly. No request here, so no base_url: use absolute URLs or data URIs for images.
from django.core.mail import EmailMessage
from django.template.loader import render_to_string
from weasyprint import HTML
def email_invoice(invoice, to):
html = render_to_string("invoices/invoice.html", {"invoice": invoice})
pdf = HTML(string=html).write_pdf()
msg = EmailMessage(f"Invoice #{invoice['number']}", "Your invoice is attached.", to=[to])
msg.attach(f"invoice-{invoice['number']}.pdf", pdf, "application/pdf")
msg.send()
Tested with Django's in-memory mail backend: the message carries invoice-9.pdf as application/pdf. Rendering takes about a second, so call it from a task queue (Celery, RQ, Django 6's tasks framework) rather than in the request if you send many.
pip install playwright
playwright install chromiumfrom django.http import HttpResponse
from django.template.loader import render_to_string
from playwright.sync_api import sync_playwright
def invoice_pdf_playwright(request, pk):
invoice = get_invoice(pk)
html = render_to_string("invoices/invoice.html", {"invoice": invoice}, request=request)
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.set_content(html, wait_until="networkidle")
pdf = page.pdf(format="A4", print_background=True, prefer_css_page_size=True)
browser.close()
response = HttpResponse(pdf, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="invoice-{pk}.pdf"'
return response
Same PDF, same "Page 1 of 1", because Chromium supports @page margin boxes and prefer_css_page_size=True lets your CSS set the size and margins. Things to know:
print_background=True is off by default; without it, background colors and images disappear.async def view, the sync API raises "It looks like you are using Playwright Sync API inside the asyncio loop". Use playwright.async_api there (async with async_playwright() as p, then await each call).p.chromium.launch(args=["--no-sandbox"]) fixes it in a trusted environment. Boxes instead of text mean missing fonts (apt install fonts-noto-core).Shotmint runs the same Chromium print path, so the output matches Playwright, but your Django process only makes an HTTP call. That fits Heroku-style dynos, Render, Fly or Cloud Run instances where a 150 MB browser and its fonts are a burden. Put your key in the SHOTMINT_KEY environment variable:
import os
import requests
from django.http import HttpResponse
from django.template.loader import render_to_string
def invoice_pdf_api(request, pk):
invoice = get_invoice(pk)
html = render_to_string("invoices/invoice.html", {"invoice": invoice}, request=request)
r = requests.post("https://shotmint.creasework.com/v1/pdf",
headers={"X-API-Key": os.environ["SHOTMINT_KEY"]},
json={"html": html, "format": "A4", "margin": "2cm"},
timeout=60)
r.raise_for_status()
response = HttpResponse(r.content, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="invoice-{pk}.pdf"'
return response
The HTML travels in the request body, so images and fonts must be reachable from the internet (absolute https:// URLs) or inlined. Other options: landscape, format (A3, A4, A5, Letter, Legal, Tabloid), scale, print_background (on by default). For a public page, send "url": "https://..." instead of html. Errors come back as JSON with a 4xx status, which raise_for_status() turns into an exception. More in the Python API guide.
| Error | Fix |
|---|---|
OSError: cannot load library 'libgobject-2.0-0' | WeasyPrint can't find Pango/GLib. Install the system packages above (on Windows, the GTK runtime). |
| Logo or CSS missing in the PDF | Relative URL with no base_url. Pass base_url, or use absolute URLs. |
| Playwright "Sync API inside the asyncio loop" | Async view: use async_playwright. |
| Colors gone (Playwright) | Add print_background=True. |
File opens as text, or named download | Set content_type="application/pdf" and a Content-Disposition filename. |
For documents you design yourself, start with WeasyPrint: no browser, good print CSS, a few lines of code. Switch to Playwright if the template needs JavaScript or must look exactly like Chrome. Use the API if you want Chrome output but can't or don't want to run Chromium on your Django host. Want to check your template first? Paste the rendered HTML into the free HTML to PDF converter; no key needed. Plain Python scripts (and xhtml2pdf): HTML to PDF in Python. Flask or FastAPI: HTML to PDF in Flask and FastAPI. Invoice layout tips: HTML invoices to PDF.
All guides: guides · integrations · comparisons
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire).
Get a free API key Full API reference