HTML to PDF in Python and Django, without WeasyPrint
Python has local options — WeasyPrint, pdfkit, xhtml2pdf — but each one means installing and maintaining native dependencies, and the most popular binary behind them, wkhtmltopdf, is no longer maintained. pdfkitt skips all of that: one requests call renders your HTML with current Chromium and returns a PDF. This guide covers the basic call, complete Django and Flask views, page options, and error handling.
One requests call
Nothing to pip install beyond requests, which you almost certainly already have. Send HTML, get PDF bytes back.
import os
import requests
resp = requests.post(
"https://api.pdfkitt.dev/v1/convert",
headers={"Authorization": "Bearer " + os.environ["PDFKITT_API_KEY"]},
json={"html": "<h1>Invoice</h1>", "options": {"page_size": "A4"}},
timeout=60,
)
resp.raise_for_status()
with open("invoice.pdf", "wb") as f:
f.write(resp.content)Django: render the template, return the PDF
Render the same template you already use for the HTML view, POST it, and hand the bytes back with a PDF content type. Because pdfkitt renders in Chromium, the PDF matches what the browser shows, including web fonts, Flexbox, and Grid.
import os
import requests
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
def invoice_pdf(request, invoice_id):
invoice = get_object_or_404(Invoice, pk=invoice_id)
html = render_to_string("invoices/show.html", {"invoice": invoice})
resp = requests.post(
"https://api.pdfkitt.dev/v1/convert",
headers={"Authorization": "Bearer " + os.environ["PDFKITT_API_KEY"]},
json={"html": html, "options": {"page_size": "A4"}},
timeout=60,
)
if resp.status_code != 200:
err = resp.json()["error"]
return JsonResponse(
{"message": err["message"], "request_id": err["request_id"]}, status=502
)
response = HttpResponse(resp.content, content_type="application/pdf")
response["Content-Disposition"] = "inline; filename=invoice-%s.pdf" % invoice.id
return responseFlask and FastAPI
The pattern is identical in any framework. In FastAPI, return a Response(content=resp.content, media_type="application/pdf"), or use httpx for an async call.
import os
import requests
from flask import Flask, Response, render_template
app = Flask(__name__)
@app.get("/reports/<int:report_id>.pdf")
def report_pdf(report_id):
html = render_template("report.html", report_id=report_id)
resp = requests.post(
"https://api.pdfkitt.dev/v1/convert",
headers={"Authorization": "Bearer " + os.environ["PDFKITT_API_KEY"]},
json={"html": html, "options": {"page_size": "Letter", "landscape": True}},
timeout=60,
)
resp.raise_for_status()
return Response(resp.content, mimetype="application/pdf")Page size, margins, and orientation
Page settings live in the options object. Page size accepts A4, Letter, or Legal; margins take any CSS length; landscape and print_background are booleans. Anything you leave out defaults to A4, portrait, with backgrounds printed. Page breaks, running headers, and footers are controlled with print CSS in your template, exactly as in Chrome's print preview. If you need a URL rendered instead of an HTML string, pass url in place of html.
{
"html": "<h1>Quarterly report</h1>",
"options": {
"page_size": "Letter",
"landscape": true,
"print_background": true,
"margins": {"top": "20mm", "right": "15mm", "bottom": "20mm", "left": "15mm"}
}
}Handling errors and timeouts
A successful render returns status 200 with PDF bytes. Anything else returns JSON with an error object holding a stable code, a message, and a request_id to quote to support. Always pass a timeout to requests: renders are capped at 30 seconds on the pdfkitt side, so a 60-second client timeout is safe. Quota and rate-limit responses are retryable after a short delay. The full list of error codes is in the API docs.
Why the local Python libraries struggle
WeasyPrint and xhtml2pdf re-implement page layout in Python, so modern CSS renders partially and the install depends on Cairo and Pango being present on every machine. pdfkit and django-wkhtmltopdf wrap a binary whose engine stopped receiving updates years ago. Running Playwright or Selenium yourself fixes fidelity but means shipping a full browser in every container, including CI. An HTTP API keeps Chromium fidelity and deletes the install and maintenance work: requests hit a warm pool, so there is no cold start.
FAQ
Why not WeasyPrint?
WeasyPrint is a good pure-Python renderer, but it implements its own layout engine, so CSS Grid, some Flexbox cases, and JavaScript-driven content do not render the way they do in a browser. It also needs Pango, Cairo, and GDK-PixBuf system libraries, which is where most Docker and Windows installs go wrong. pdfkitt renders in real Chromium and needs nothing installed.
What about pdfkit and wkhtmltopdf?
python-pdfkit is a thin wrapper around the wkhtmltopdf binary, which was archived in 2023 and uses a WebKit build from around 2012: no Flexbox, no Grid, patchy web-font support, and a known security advisory. It still works for simple documents, but every modern CSS feature is a gamble.
Can I use it from Django, Flask, or FastAPI?
Yes. Any framework that can make an HTTP request works. Render your template to a string, POST it, and return the bytes with an application/pdf content type. The Django and Flask examples on this page are complete views you can paste in.
Does the API run JavaScript inside my HTML?
Not by default. Static HTML and CSS render as-is. If your page needs its scripts to run, such as client-rendered charts, set javascript: true in the request and the page executes in sandboxed Chromium before capture, with a 30-second cap per render.
Should I call the API synchronously inside a web request?
For a single invoice or report, yes: typical latency is well under a second, so a synchronous view is fine. For batch jobs such as generating hundreds of statements, push the work to Celery, RQ, or a background task so one slow render cannot block a worker.
Is my HTML stored anywhere?
No. HTML is processed in memory to render the PDF and is never written to storage or logs. Only request metadata such as timing and status is recorded.
Try pdfkitt free — 1,000 PDFs/month, no credit card
Get an API key and send your first PDF from Python in minutes.