How to Fix Blank PDFs When Converting HTML with Python pdfkit in Django
Find the exact cause of blank Django PDFs by separating template rendering, wkhtmltopdf execution, assets, JavaScript timing, and response handling.
A blank PDF from Django can originate in three different places: the HTML template rendered no content, pdfkit/wkhtmltopdf failed to load or render that HTML, or Django returned the generated bytes incorrectly. Debug those stages in order. Render the exact HTML first, then run the emitted wkhtmltopdf command with stderr visible, then verify assets, JavaScript timing, encoding, and the HTTP response.
pdfkit is a Python wrapper around the wkhtmltopdf executable, so the installed binary, its command-line options, and its stderr are part of the diagnosis. See the pdfkit documentation and the wkhtmltopdf usage reference.
1. Prove whether Django rendered the HTML
Do not start by changing PDF options. First inspect the HTML that the Django process actually gives to pdfkit.
Render the template through the view
Add a temporary format switch to the view, or use the HTML debugging mode supplied by your integration. The django-pdfkit documentation describes an ?html query option that returns HTML instead of a PDF.
# views.py
from django.http import HttpResponse
from django.template.loader import render_to_string
import pdfkit
def invoice_pdf(request, invoice_id):
context = {"invoice": load_invoice(invoice_id)}
html = render_to_string("invoices/invoice.html", context, request=request)
# Temporary diagnostic endpoint: /invoices/123.pdf?html=1
if request.GET.get("html") == "1":
return HttpResponse(html, content_type="text/html; charset=utf-8")
pdf_bytes = pdfkit.from_string(
html,
False,
options={
"encoding": "UTF-8",
"quiet": False,
},
)
response = HttpResponse(pdf_bytes, content_type="application/pdf")
response["Content-Disposition"] = f'inline; filename="invoice-{invoice_id}.pdf"'
return response
Open the HTML endpoint directly and inspect the response source, not only the browser’s final DOM. Check that:
- The expected text and elements are present in the response body.
- The template path is the one used by this view.
- Context variables are populated and are not hidden by an
{% if %}branch. - Loops contain records and do not silently iterate over an empty queryset.
- Any content normally inserted by browser JavaScript already exists, or is intentionally generated later.
If this HTML is blank, fix Django template selection, context construction, conditionals, authentication, or view logic before touching wkhtmltopdf. If the HTML contains the content, continue with converter diagnostics.
2. Confirm the binary and capture its diagnostics
The Django web process may use a different PATH from your shell. Verify the executable from the same user, container, virtual machine, or worker that handles the request.
which wkhtmltopdf
wkhtmltopdf --version
python -c "import pdfkit; print(pdfkit.configuration())"
Set the binary explicitly when discovery is unreliable. Integration packages use different setting names; use the setting documented by the package installed in your project.
# Direct pdfkit configuration
import pdfkit
config = pdfkit.configuration(
wkhtmltopdf="/usr/local/bin/wkhtmltopdf"
)
pdf_bytes = pdfkit.from_string(
html,
False,
configuration=config,
options={"encoding": "UTF-8", "quiet": False},
)
# django-wkhtmltopdf setting (package-specific)
WKHTMLTOPDF_CMD = "/usr/local/bin/wkhtmltopdf"
# django-pdfkit setting (package-specific)
WKHTMLTOPDF_BIN = "/usr/local/bin/wkhtmltopdf"
Do not copy both settings blindly. django-wkhtmltopdf and django-pdfkit expose different configuration names.
Keep stderr and reproduce the command
pdfkit normally uses quiet mode. Disable it while diagnosing, log the exact command and exit status, and run that command manually in the same environment. The pdfkit troubleshooting guidance specifically recommends reproducing the command shown in an error.
import logging
import pdfkit
logger = logging.getLogger(__name__)
config = pdfkit.configuration(wkhtmltopdf="/usr/local/bin/wkhtmltopdf")
options = {
"encoding": "UTF-8",
"quiet": False,
}
try:
pdf_bytes = pdfkit.from_string(
html,
False,
configuration=config,
options=options,
)
except OSError:
logger.exception("wkhtmltopdf could not be started")
raise
except Exception:
logger.exception("wkhtmltopdf failed; preserve command and stderr")
raise
Look for messages about a missing executable, unsupported options, blocked local files, failed URLs, JavaScript errors, TLS certificates, or a non-zero exit status. A PDF file that exists is not proof that all resources loaded successfully.
3. Make assets reachable from the converter
A browser may resolve relative URLs, authenticated routes, and development-server paths that are unavailable to a server-side converter. Test every stylesheet, image, font, and remote resource from the conversion environment.
Prefer absolute, converter-reachable URLs
{% load static %}
<link rel="stylesheet" href="{{ request.scheme }}://{{ request.get_host }}{% static 'invoices/invoice.css' %}">
<img src="{{ request.scheme }}://{{ request.get_host }}{% static 'invoices/logo.png' %}" alt="Company logo">
For production, configure Django’s collected static files and ensure the web server exposes them. django-wkhtmltopdf documents a workflow based on STATIC_ROOT; an uncollected or inaccessible static directory produces unstyled or apparently empty output.
Local files and file access
wkhtmltopdf disables local-file access by default in relevant builds. If your HTML references file:// resources, use the binary’s documented local-file options and allow only the directories required by the document.
options = {
"encoding": "UTF-8",
"enable-local-file-access": None,
# Prefer a narrow allow-list when supported by your wkhtmltopdf build:
# "allow": "/srv/app/static",
"quiet": False,
}
Do not broadly enable filesystem access for untrusted HTML. The wkhtmltopdf security guidance states that it is not recommended for HTML you do not explicitly trust; local-file permissions can expose data outside the document’s intended scope.
Check URLs independently
curl -I https://your-host.example/static/invoices/invoice.css
curl -I https://your-host.example/static/invoices/logo.png
curl -I https://your-host.example/invoices/123/
Run these checks from the same network namespace as the Django worker. Private hostnames, split DNS, internal authentication, expired certificates, and firewall rules commonly work in a developer browser but fail in the converter.
4. Handle JavaScript only when the document needs it
If the initial HTML already contains the invoice text, adding a delay will not fix a blank conversion. JavaScript matters when scripts fetch data, draw a chart, expand a section, or replace a loading placeholder.
options = {
"encoding": "UTF-8",
"enable-javascript": None,
# Give a script time to finish only when it is required.
"javascript-delay": 1000,
"quiet": False,
}
Use the smallest reliable delay, or wait for a deterministic server-rendered state where your wkhtmltopdf build supports it. Verify that API calls made by the page are reachable without browser-only credentials. A script that waits forever, throws an exception, or depends on unsupported modern browser features can leave a loading shell that looks blank in the PDF.
5. Preserve Unicode and document metadata
Missing characters can make a document appear incomplete, especially when the visible content is mostly non-ASCII text. Declare UTF-8 in the template and pass the encoding option.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Invoice</title>
</head>
<body>{{ invoice.customer_name }}</body>
</html>
options = {
"encoding": "UTF-8",
"quiet": False,
}
django-wkhtmltopdf’s usage documentation also recommends UTF-8 content metadata for Unicode output.
6. Return the PDF bytes correctly from Django
When conversion succeeds but the downloaded file is empty or unreadable, inspect the response path. Use False as pdfkit’s output target to receive bytes, set the correct content type, and do not decode or stringify the bytes.
from django.http import HttpResponse
pdf_bytes = pdfkit.from_string(html, False, configuration=config, options=options)
response = HttpResponse(pdf_bytes, content_type="application/pdf")
response["Content-Length"] = str(len(pdf_bytes))
response["Content-Disposition"] = 'attachment; filename="invoice.pdf"'
return response
Check the response in a command-line client:
curl -v https://your-host.example/invoices/123.pdf -o invoice.pdf
file invoice.pdf
ls -l invoice.pdf
A valid PDF normally begins with the bytes %PDF-. If the response instead contains an HTML error page, a Django debug traceback, or a zero-byte body, fix the view or upstream proxy before debugging the renderer.
7. A complete minimal Django example
# views.py
from django.http import HttpResponse
from django.template.loader import render_to_string
import pdfkit
def invoice_pdf(request, invoice_id):
invoice = load_invoice(invoice_id)
html = render_to_string(
"invoices/invoice.html",
{"invoice": invoice},
request=request,
)
if request.GET.get("html") == "1":
return HttpResponse(html, content_type="text/html; charset=utf-8")
config = pdfkit.configuration(wkhtmltopdf="/usr/local/bin/wkhtmltopdf")
options = {
"encoding": "UTF-8",
"enable-local-file-access": None,
"quiet": False,
}
pdf_bytes = pdfkit.from_string(
html,
False,
configuration=config,
options=options,
)
response = HttpResponse(pdf_bytes, content_type="application/pdf")
response["Content-Disposition"] = (
f'inline; filename="invoice-{invoice_id}.pdf"'
)
return response
{# templates/invoices/invoice.html #}
{% load static %}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="{{ request.scheme }}://{{ request.get_host }}{% static 'invoices/invoice.css' %}">
</head>
<body>
<h1>Invoice {{ invoice.number }}</h1>
<p>{{ invoice.customer_name }}</p>
<p>Total: {{ invoice.total }}</p>
</body>
</html>
8. Troubleshooting checklist
| Symptom | Likely cause | Fix |
|---|---|---|
?html=1 is blank |
Wrong template, empty context, false conditional, or view logic | Inspect response source and log the context values before conversion. |
| HTML has content, PDF is blank | Binary failure, unsupported markup, blocked resources, or JavaScript timing | Run the exact wkhtmltopdf command with stderr and quiet disabled. |
No wkhtmltopdf executable found |
Binary is absent or not on the Django process PATH | Install it in the runtime image or set the package-specific executable path. |
| Styles and images are missing | Relative, private, or unreachable asset URLs | Use absolute URLs, collect static files, and test them from the worker environment. |
| Local images fail | Local-file access is disabled | Allow only the required directory using the options supported by your binary. |
| Page contains a loading spinner | Client-side JavaScript has not completed | Confirm scripts are enabled, APIs are reachable, and add a bounded delay only when needed. |
| Unicode text disappears | Missing or inconsistent encoding metadata | Add UTF-8 meta tags and pass encoding: UTF-8. |
| PDF download is zero bytes | Bytes were not returned or response handling changed them | Use from_string(html, False), pass bytes directly, and inspect Content-Length. |
| PDF contains a Django error page | Exception occurred before or during conversion | Check HTTP status, server logs, executable permissions, and converter stderr. |
| Works locally but fails in production | Different binary, PATH, network, fonts, permissions, or static configuration | Compare versions and environment values inside the production worker. |
9. Performance, reliability, and cost considerations
- Render once: build the HTML string once and pass it to pdfkit. Avoid making the template call remote services for every row.
- Keep assets close: local or same-network assets reduce DNS, TLS, and network delays, but constrain local-file permissions carefully.
- Bound waits: a JavaScript delay increases every request. Use it only for content that genuinely appears after page load.
- Set request timeouts: protect the Django worker from a page or resource that never responds. Queue long documents when synchronous requests would exhaust workers.
- Log the important evidence: renderer version, executable path, option set, elapsed time, exit status, stderr, input URL or template identifier, and output byte length.
- Make retries deliberate: retry transient network failures, but do not retry deterministic template errors or permission failures indefinitely.
- Control untrusted input: wkhtmltopdf is not recommended for HTML you do not explicitly trust. Restrict network and filesystem access rather than enabling broad access for convenience.
- Cost: self-hosted pdfkit costs compute, storage, and operational time. If you use a hosted capture service, check how it bills failed loads and cache hits before estimating usage.
Or skip the browser setup
If your goal is a clean capture of a reachable web page rather than a Django-specific PDF renderer, ScreenshotNeo provides a single GET request for PNG, JPEG, WebP, or PDF output. Its capture flow accepts cookie and consent banners like a visitor, then removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.
See the ScreenshotNeo API documentation for authentication and options.
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,
)
r.raise_for_status()
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}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const buffer = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('shot.webp', buffer);
You get 1,000 screenshots a month free with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
FAQ
Why does the browser show the page but pdfkit produce a blank PDF?
The browser may execute JavaScript, resolve authenticated assets, and use a different network and font environment. Compare the raw HTML and converter stderr from the Django runtime.
Should I add a long JavaScript delay first?
No. Add a bounded delay only when required content is created after the initial HTML loads. A delay cannot repair an empty template or an inaccessible stylesheet.
Which Django setting controls the wkhtmltopdf path?
It depends on the integration. django-wkhtmltopdf documents WKHTMLTOPDF_CMD; django-pdfkit documents WKHTMLTOPDF_BIN. Direct pdfkit code can pass pdfkit.configuration(wkhtmltopdf=...).
Can I enable local-file access for every document?
Only for trusted, controlled HTML and with a narrow allow-list. Broad filesystem access increases the impact of untrusted input.
How do I know whether Django returned a real PDF?
Inspect the HTTP status and content type, check the response length, and verify that the file begins with the PDF signature %PDF- instead of an HTML error response.


