How to Generate PDFs With wkhtmltopdf in Django
Render Django templates into PDFs with wkhtmltopdf, return them safely, handle assets and errors, and evaluate production trade-offs.
Direct answer: render a controlled Django template to HTML, pass that HTML to the separate wkhtmltopdf executable, then return the resulting PDF with Django’s HttpResponse or FileResponse. Keep the HTML and JavaScript under your control, verify the wkhtmltopdf build in the same environment used for deployment, and design temporary-file cleanup and process timeouts explicitly.
How the pipeline works
- Django loads and authorizes the record to print.
- Django renders a print-oriented HTML template.
- Your application invokes
wkhtmltopdfwith an input HTML file or URL and an output PDF path. - Django streams the PDF or returns its bytes with
application/pdf.
wkhtmltopdf accepts page objects and an output filename; its command syntax also supports cover and table-of-contents objects. Options can be global or scoped to a page object. See the official command-line usage.
Install and verify wkhtmltopdf
The official downloads page identifies the 0.12.6 series as the current stable series and dates that release to June 11, 2020. It lists operating-system and architecture downloads, but a listed package does not guarantee support for every newer distribution. Some capabilities require the project’s patched Qt build, while distribution packages may omit them. Check the executable you actually deploy.
# Confirm the executable and build
which wkhtmltopdf
wkhtmltopdf --version
# Convert a local file as a smoke test
wkhtmltopdf --enable-local-file-access sample.html sample.pdf
Record the binary version during deployment and run a representative conversion on the target operating system and architecture. The project status page explains that wkhtmltopdf uses Qt 4, unsupported since 2015, and WebKit that had not been updated since 2012 at the time of that page: project status.
Create a print-friendly Django template
Use a dedicated template so screen navigation, animations and interactive controls do not leak into the PDF. Keep CSS simple and deterministic. If you reference static assets, make their URLs or local paths reachable by the converter and verify them in the deployment environment.
{% raw %}{% load static %}
Invoice {{ invoice.number }}
Invoice {{ invoice.number }}
Issued {{ invoice.issued_at|date:"Y-m-d" }}
Description Quantity Amount
{% for line in invoice.lines.all %}
{{ line.description }} {{ line.quantity }} {{ line.amount }}
{% endfor %}
Total: {{ invoice.total }}
{% endraw %}
Minimal Django view using temporary files
The following view is a teaching implementation. It authorizes the invoice, writes rendered HTML to a temporary directory, invokes wkhtmltopdf with a timeout, reads the PDF before cleanup, and returns bytes. Reading the bytes before the temporary directory closes avoids returning a path to a file that has already been deleted.
from pathlib import Path
import subprocess
import tempfile
from django.conf import settings
from django.http import HttpResponse, Http404
from django.template.loader import render_to_string
from .models import Invoice
def invoice_pdf(request, invoice_id):
invoice = Invoice.objects.filter(
id=invoice_id,
account=request.user.account,
).prefetch_related("lines").first()
if invoice is None:
raise Http404
html = render_to_string("invoices/invoice.html", {"invoice": invoice})
wkhtmltopdf = getattr(settings, "WKHTMLTOPDF_PATH", "wkhtmltopdf")
with tempfile.TemporaryDirectory() as directory:
directory_path = Path(directory)
html_path = directory_path / "invoice.html"
pdf_path = directory_path / "invoice.pdf"
html_path.write_text(html, encoding="utf-8")
command = [
wkhtmltopdf,
"--encoding", "utf-8",
"--enable-local-file-access",
str(html_path),
str(pdf_path),
]
try:
subprocess.run(
command,
check=True,
capture_output=True,
text=True,
timeout=30,
)
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.CalledProcessError) as exc:
# Log stderr and the invoice/request identifiers in production.
return HttpResponse("PDF generation failed", status=502)
pdf_bytes = pdf_path.read_bytes()
response = HttpResponse(pdf_bytes, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="invoice-{invoice.number}.pdf"'
return response
Set WKHTMLTOPDF_PATH to an absolute path when the executable is not on the web worker’s PATH. Do not put an already closed file object into a response.
Use FileResponse for a binary file object
Django documents FileResponse as optimized for binary files. It can set an attachment filename and closes the file automatically. A file must remain open until Django consumes it; for BytesIO, rewind to position zero first. See the Django response reference.
from io import BytesIO
from django.http import FileResponse
buffer = BytesIO(pdf_bytes)
buffer.seek(0)
return FileResponse(
buffer,
as_attachment=True,
filename="invoice.pdf",
content_type="application/pdf",
)
Call wkhtmltopdf directly from a shell
# HTML file to PDF
wkhtmltopdf --encoding utf-8 --enable-local-file-access invoice.html invoice.pdf
# Remote page to PDF (only for pages you are authorized to fetch)
wkhtmltopdf --print-media-type https://example.com/report report.pdf
For a Django endpoint, a client can download the generated document with cURL:
curl -fL -o invoice.pdf https://your-domain.example/invoices/123.pdf
Options you will commonly need
| Need | Typical option or design | Reason |
|---|---|---|
| Character encoding | --encoding utf-8 |
Preserves non-ASCII text when the template and fonts support it. |
| Local CSS or images | --enable-local-file-access |
Allows local files; restrict the files your process can read. |
| Paper and margins | --page-size A4, --margin-top, --margin-bottom |
Make pagination explicit instead of relying on defaults. |
| Headers and footers | --header-html, --footer-right |
Add page metadata where your build supports it. |
| Landscape output | --orientation Landscape |
Useful for wide tables. |
| Print CSS | --print-media-type |
Uses print media rules when the page defines them. |
| Page breaks | CSS such as page-break-inside: avoid |
Reduces rows splitting across pages; test complex tables. |
| JavaScript timing | --javascript-delay or a controlled template |
Allows a page to finish a small amount of client rendering. |
Option availability and behavior depend on the exact build. Run wkhtmltopdf --extended-help in that environment rather than assuming a vendor package includes every patched-Qt feature.
Assets, fonts and JavaScript
- Prefer absolute HTTPS asset URLs or deliberately permitted local paths.
- Ensure the worker can resolve DNS and reach required hosts.
- Bundle fonts on the server when consistent output matters, and verify licensing.
- Keep JavaScript minimal. A fixed delay is less reliable than rendering data into the HTML before conversion.
- Do not depend on browser APIs or modern CSS that the old WebKit engine may not implement.
Security requirements
The official downloads page warns: “Do not use wkhtmltopdf with any untrusted HTML – be sure to sanitize any user-supplied HTML/JS, otherwise it can lead to complete takeover of the server it is running on!” Treat this as a hard boundary.
- Render approved templates with validated data rather than accepting arbitrary HTML.
- Do not interpolate user input into shell command strings; pass an argument list to
subprocess.run. - Run the converter as a low-privilege account with a restricted filesystem and network policy.
- Consider process isolation and Mandatory Access Control such as AppArmor or SELinux, as discussed on the project status page.
- Authorize the requested object before rendering it and avoid exposing internal URLs or credentials.
Production reliability and performance
- Set a subprocess timeout and return a controlled error when it expires.
- Limit concurrent conversions so PDF jobs cannot exhaust CPU, memory or process slots.
- Move slow or bursty generation to a task queue and store completed PDFs when requests should not wait.
- Capture stderr, exit status, input identifiers and duration in structured logs.
- Use deterministic HTML and cache PDFs when the underlying record has not changed.
- Clean every temporary file on success, failure and timeout.
- Test representative documents after changing the binary, base image, fonts or CSS.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
[Errno 2] No such file or directory |
Executable is missing or not on PATH. |
Install a supported build and set an absolute WKHTMLTOPDF_PATH. |
| Blank or incomplete PDF | Asset URL, JavaScript timing or network access failure. | Inspect stderr, use reachable asset URLs, reduce client rendering and test with a delay only when needed. |
| Images or CSS missing | Relative paths or local-file restrictions. | Use correct absolute URLs or --enable-local-file-access for controlled files. |
| Fonts or symbols differ | Font absent on the worker or old WebKit limitations. | Install permitted fonts, declare fallbacks and compare output in deployment. |
| Process hangs | Remote resource, script or renderer issue. | Set a timeout, restrict network access, log stderr and isolate the job. |
| Features unavailable | Distribution package lacks patched Qt features. | Inspect --version, compare with the official build and choose a compatible package. |
| PDF is deleted before download | Response references a path inside a closed temporary directory. | Read bytes before cleanup, keep a file open for FileResponse, or persist the file. |
When wkhtmltopdf is the wrong renderer
The project status recommends considering WeasyPrint or commercial Prince for report generation from HTML your application controls, and a browser automation tool such as Puppeteer when converting a site that depends on dynamic JavaScript. Compare HTML/CSS fidelity, JavaScript requirements, packaging, maintenance and licensing before switching. These are project recommendations, not drop-in compatibility guarantees.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. For a PDF capture, send the target URL to the API; see the ScreenshotNeo API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
ScreenshotNeo removes cookie banners, newsletter popups and chat widgets before capture. Bot checks, blank pages and failed loads are never billed, and response headers identify the page verdict and billing result. Its MCP server lets Claude, Cursor and other MCP clients take screenshots. The Free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000.
Sign up for ScreenshotNeo’s free 1,000-shot plan.
FAQ
Does Django generate the PDF itself?
No. Django renders the HTML and returns the result; wkhtmltopdf is a separate executable that performs the conversion.
Should I return HttpResponse or FileResponse?
Use HttpResponse when you already hold PDF bytes. Use FileResponse for an open binary file object that Django can consume and close.
Can I convert arbitrary user HTML?
No. The project warns that untrusted HTML or JavaScript can lead to complete server takeover. Sanitize and isolate, or use controlled templates.
Why does a package behave differently from documentation?
Some distributions omit features from the patched Qt build. Check the actual executable and test it on the deployment platform.
When should I choose a browser automation tool?
Consider one when the page depends on substantial, modern client-side JavaScript; the wkhtmltopdf project specifically names browser automation such as Puppeteer for that situation.


