Generate PDFs from HTML in Django

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.

Quick answer

ConverterPick 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.

The template

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>

1. WeasyPrint

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.

Emailing the PDF

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.

2. Playwright (headless Chromium)

pip install playwright
playwright install chromium
from 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:

3. An API (no browser on your server)

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.

Common errors

ErrorFix
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 PDFRelative 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 downloadSet content_type="application/pdf" and a Content-Disposition filename.

Which one?

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.

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons

Try it with 100 free renders

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