Django HTML to PDF
The Django answers are django-weasyprint, xhtml2pdf and django-wkhtmltopdf,
and each asks for something at the system level. WeasyPrint needs Pango, cairo and
GDK-PixBuf present in every environment — which is fine locally and an afternoon on
a slim container image. xhtml2pdf is pure Python and supports a subset of CSS 2.1,
so anything built this decade does not survive it. django-wkhtmltopdf shells out
to a binary whose engine predates flexbox.
This needs nothing installed beyond requests.
Render a Django template
render_to_string gives you the HTML; the API gives you the PDF.
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
def invoice_pdf(request, invoice_id):
invoice = get_object_or_404(Invoice, pk=invoice_id)
html = render_to_string(
"invoices/show.html",
{"invoice": invoice},
request=request,
)
response = requests.post(
"https://renderpaper.com/v1/render",
headers={"X-API-Key": os.environ["RENDER_API_KEY"]},
json={"template": html},
timeout=60,
)
if response.status_code != 200:
return HttpResponse("Could not render the document", status=502)
pdf = HttpResponse(response.content, content_type="application/pdf")
pdf["Content-Disposition"] = f'attachment; filename="invoice-{invoice_id}.pdf"'
return pdf
Wire it up as usual:
from django.urls import path
from . import views
urlpatterns = [
path("invoices/<int:invoice_id>/pdf", views.invoice_pdf, name="invoice-pdf"),
]
Passing request= to render_to_string matters if your template uses
{% url %} or context processors — without it those resolve differently or fail.
Static files and images
Django serves CSS and images at URLs the renderer cannot reach if they are behind
your authentication, or on localhost during development. Two options that both
work:
Inline the stylesheet into the template used for PDFs:
{% load static %}
<style>{% include "invoices/pdf.css" %}</style>
Or embed images as data URIs so the document carries everything it needs:
import base64
from pathlib import Path
def data_uri(path: Path) -> str:
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:image/png;base64,{encoded}"
A self-contained document is also faster to render, because nothing has to be fetched while the page is being laid out.
Stored templates: send data, not markup
Rendering a Django template keeps the document in your repository, so a design change is a deploy. Store it once and post data.
import os
import requests
TEMPLATE_ID = "your-template-id"
response = requests.post(
f"https://renderpaper.com/v1/templates/{TEMPLATE_ID}/render",
headers={"X-API-Key": os.environ["RENDER_API_KEY"]},
json={
"data": {
"InvoiceNumber": "2026-0142",
"Customer": {"Name": "Northwind Trading AB"},
"LineItems": [
{"Description": "Consulting", "Quantity": 12, "Amount": 14400},
{"Description": "Hosting", "Quantity": 1, "Amount": 4350},
],
"Total": 18750,
}
},
timeout=60,
)
response.raise_for_status()
with open("invoice.pdf", "wb") as f:
f.write(response.content)
The stored template is a Go html/template. The loop reads much like Django's:
<h1>Invoice {{.InvoiceNumber}}</h1>
<p>{{.Customer.Name}}</p>
<table>
{{range .LineItems}}
<tr><td>{{.Description}}</td><td>{{.Quantity}}</td><td>{{.Amount}}</td></tr>
{{end}}
</table>
Do it in Celery for anything slow
A render takes a second or two, which is too long to hold a worker for a batch:
import os
import requests
from celery import shared_task
from django.core.files.base import ContentFile
from django.template.loader import render_to_string
from .models import Invoice
@shared_task
def render_invoice(invoice_id: int) -> None:
invoice = Invoice.objects.get(pk=invoice_id)
html = render_to_string("invoices/show.html", {"invoice": invoice})
response = requests.post(
"https://renderpaper.com/v1/render",
headers={"X-API-Key": os.environ["RENDER_API_KEY"]},
json={"template": html},
timeout=120,
)
response.raise_for_status()
invoice.pdf.save(f"invoice-{invoice_id}.pdf", ContentFile(response.content))
Why not django-weasyprint
WeasyPrint's CSS Paged Media support is better than Chromium's — named pages, margin boxes, running elements — and for print-first typography that matters.
The trade is the rest of CSS and the install. Grid is unsupported and flexbox arrived late, so a layout built with modern tools does not survive. And Pango and cairo have to be present in every environment you deploy to, which is a class of deployment problem a pure-Python dependency does not have.
Why not xhtml2pdf
Pure Python and no system libraries, which is genuinely appealing. It targets a subset of CSS 2.1, so it suits a fixed, simple document generated entirely by your code and nothing that a designer will open.
Try it without a key
https://renderpaper.com/sample.pdf is a real render, no account. The free tier is
50 documents a month with no card.