How Django projects usually make PDFs
Django has no PDF engine of its own, so every project picks a library. Django’s official how to guide on creating PDF files uses ReportLab, which is why so many codebases start there. Others prefer to keep using Django templates and convert the resulting HTML. Each route is valid; they differ in who can change the layout and what your deployment has to carry.
| Library | You design with | Worth knowing |
|---|---|---|
| ReportLab | Python code: a canvas, or Platypus flowables | The approach shown in Django’s own docs; precise and quick, but a designer cannot edit the layout as HTML |
| WeasyPrint | Django templates rendered to HTML and CSS | Excellent paged media support; needs Pango and related system libraries in each image and runs no JavaScript |
| xhtml2pdf | Django templates, converted through ReportLab | Pure Python and easy to install; understands a limited subset of CSS |
| CastPDF | Django templates or a saved template filled with JSON | Chromium with a print engine on our side; one HTTPS request per document and a plan above the free allowance |
If your PDFs are a few fixed forms and ReportLab already draws them, there is little reason to change. The pressure usually starts elsewhere: marketing wants the certificate to match the website, a report needs CSS grid, or the Docker image keeps breaking when the base layer updates its font and graphics libraries. Sending HTML to an API removes the native dependencies entirely, and the layout stays in the template language your team already writes.
Store the key in settings
Treat the API key like your SECRET_KEY: read it from the environment in settings.py and never commit it. Copy a test key from the dashboard (it starts with cpdf_test_); test documents are free, carry a watermark and never count towards your plan. Failing fast on a missing variable is kinder than discovering it on the first download.
import os
# Raises KeyError at startup if the variable is missing, which is what you want.
CASTPDF_API_KEY = os.environ["CASTPDF_API_KEY"]
CASTPDF_CERTIFICATE_TEMPLATE_ID = os.environ.get("CASTPDF_CERTIFICATE_TEMPLATE_ID", "")A view that returns a course certificate
Picture a training platform where students download a certificate after finishing a course. The view below only finds enrolments that belong to the signed in user and are completed, sends the details to the certificate starter template and returns the PDF as an attachment. Using the enrolment ID in the idempotency key means a student who clicks twice gets the same document, billed once.
import logging
import requests
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from .models import Enrollment
logger = logging.getLogger(__name__)
CASTPDF_URL = "https://api.castpdf.com/v1/pdf"
@login_required
def certificate_pdf(request, enrollment_id):
enrollment = get_object_or_404(
Enrollment.objects.select_related("course"),
pk=enrollment_id,
student=request.user,
completed_on__isnull=False,
)
payload = {
"template_id": settings.CASTPDF_CERTIFICATE_TEMPLATE_ID,
"data": {
"issuer": {"name": "Fernhill Academy", "short": "FA", "verify_url": "fernhill.example/verify"},
"recipient": {"name": request.user.get_full_name()},
"course": {
"title": enrollment.course.title,
"hours": enrollment.course.hours,
"format": "Online, self paced",
"grade": enrollment.grade,
},
"completed_on": enrollment.completed_on.isoformat(),
"signatory": {"name": "Dr Amara Osei", "title": "Head of Learning"},
"certificate_id": f"FA-{enrollment.pk:06d}",
},
"filename": f"certificate-{enrollment.pk}",
}
res = requests.post(
CASTPDF_URL,
headers={
"Authorization": f"Bearer {settings.CASTPDF_API_KEY}",
"Idempotency-Key": f"certificate-{enrollment.pk}",
},
json=payload,
timeout=(10, 75),
)
if not res.ok:
error = res.json().get("error", {})
logger.error("CastPDF %s: %s", error.get("code"), error.get("message"))
return HttpResponse("The certificate could not be created.", status=502, content_type="text/plain")
response = HttpResponse(res.content, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="certificate-{enrollment.pk}.pdf"'
return responseWire it up with path("enrollments/<int:enrollment_id>/certificate.pdf", views.certificate_pdf, name="certificate-pdf") in your urls.py, and link to it with {% url "certificate-pdf" enrollment.pk %}. Change attachment to inline if you would rather the certificate open in a browser tab. HttpResponse is the right class here because the whole PDF is already in memory; FileResponse is meant for file objects you stream from disk or storage.
Render a Django template, then send the HTML
Saved templates suit documents that marketing or operations want to adjust without a deploy. For internal paperwork, you may prefer to keep everything in your repository and use the Django template language you already know. In that case, build the HTML with render_to_string and send it as html with no data key, so CastPDF prints it exactly as Django produced it. The example creates a class register for a tutor, with one row per attendee.
import requests
from django.conf import settings
from django.template.loader import render_to_string
def class_register_pdf(session):
html = render_to_string(
"courses/pdf/class_register.html",
{"session": session, "attendees": session.attendees.order_by("last_name")},
)
res = requests.post(
"https://api.castpdf.com/v1/pdf",
headers={"Authorization": f"Bearer {settings.CASTPDF_API_KEY}"},
json={
"html": html,
"mode": "print",
"format": "A4",
"orientation": "landscape",
"margins": "15mm",
"filename": f"register-{session.pk}",
},
timeout=(10, 75),
)
res.raise_for_status()
return res.content"mode": "print" matters for registers and other long tables: the print engine repeats the <thead> at the top of every page and keeps rows whole. Put a <style> block in the template itself, or link a stylesheet by its full public https URL. Paths like /static/css/print.css are relative to your site, which the renderer cannot see, and addresses such as 127.0.0.1 used by runserver are refused on purpose. Because Django templates and Liquid share the {{ }} syntax, never add a data key to a request whose HTML Django already rendered.
Long documents belong in a task queue
A synchronous view is fine for a one page certificate. For a 30 page course report, move the call into a Celery or RQ task, save the bytes with default_storage.save() and let the page poll or show a link once the file exists. Alternatively, ask for "response": "url" and store the signed link CastPDF returns on the model; it works without a key until its expires_at, which follows your plan’s retention period. Keep the same idempotency key across task retries so a crashed worker never produces a duplicate.
{
"template_id": "2c9d7e1f-3a4b-4c5d-8e6f-9a0b1c2d3e4f",
"data": {
"issuer": { "name": "Fernhill Academy", "short": "FA", "verify_url": "fernhill.example/verify" },
"recipient": { "name": "Tomasz Nowak" },
"course": { "title": "Applied Data Protection", "hours": 24, "format": "Online, self paced", "grade": "Distinction" },
"completed_on": "2026-09-28",
"signatory": { "name": "Dr Amara Osei", "title": "Head of Learning" },
"certificate_id": "FA-004417"
},
"filename": "certificate-4417",
"response": "url"
}Production checklist for Django
- Always set a
timeoutonrequests.post. Without one a stuck connection holds a Gunicorn worker indefinitely. A short connect limit and a read limit around 75 seconds works well. - Check your Gunicorn
--timeout(30 seconds by default) and any proxy timeout in front of it. A render may take up to 30 seconds, so long documents should run in a task, not in the request. - Keep
CASTPDF_API_KEYout ofsettings.pyliterals, fixtures and Git history. Load it from the environment or a secrets manager, and switch from the test key to acpdf_live_key at launch. - Use
select_relatedandprefetch_relatedwhen building the data, so a 200 row report does not run 200 queries before the PDF request even starts. - In tests, patch the HTTP call (for example with the
responseslibrary orunittest.mock) and return a small fixture PDF, so CI never needs a key. - Respect the per document limits of 50 pages and 40 MB. Photos uploaded to an
ImageFieldare often full resolution, so serve a resized rendition in the PDF.
Troubleshooting Django PDF views
| Symptom | Likely cause | Fix |
|---|---|---|
| The PDF has no styling | The stylesheet was linked with a relative /static/ path or a localhost address | Inline the CSS or link a public https URL; check blocked_resources in a url response |
template_render_error (422) | Django template tags such as {% url %} were sent to Liquid inside a saved template | Render Django templates locally and send html without data |
WORKER TIMEOUT in the Gunicorn log | The worker timeout is shorter than the render plus network time | Raise the timeout or move the document to a Celery task |
invalid_api_key (401) | The environment variable is set in your shell but not for the service | Set it in the process manager, container or platform settings |
content_lost (422) | Print mode found a wide table or image that would be cut off | Let cells wrap, reduce widths or switch to landscape |
The browser saves the file as certificate_pdf | No Content-Disposition filename was set | Add attachment; filename="..." to the response |
The quickstart shows how to create keys and pick a starter, and the API reference documents every field used above. For Python outside Django, including httpx and retry helpers, read the Python page. The Laravel page shows the same controller and queue patterns in PHP.