Generate a PDF from JSON in Python (FastAPI and Django)

Install quirepdf, build a dict with your data and call Quire().render(data); the result's content attribute holds the PDF bytes, and save(path) writes them to a file. In FastAPI or Django, return result.content with the media type application/pdf.

pip install quirepdf
export QUIRE_API_KEY=qk_live_...   # from: npx quirepdf-cli login [email protected]
from quirepdf import Quire

quire = Quire()  # reads QUIRE_API_KEY

slip = {
    "title": "Packing slip",
    "number": "ORD-88213",
    "date": "3 Oct 2026",
    "from": {"name": "Hearth & Loom", "email": "[email protected]"},
    "to": {"name": "Lena Fischer", "address": ["Torstraße 12", "10119 Berlin", "Germany"]},
    "carrier": "DHL Paket",
    "items": [
        {"sku": "TS-BLK-M", "item": "Organic tee, black, M", "qty": 2},
        {"sku": "SK-GRY-L", "item": "Wool socks, grey, L", "qty": 3},
    ],
    "notes": "Thank you for your order. Returns are free within 30 days.",
}

result = quire.render(slip)
result.save("packing-slip.pdf")
print(result.template, result.pages)  # document 1

The SDK needs Python 3.9+ and uses only the standard library. No template is required: Quire lays out any dict. Here title, number, from and to build the header, carrier and date go into a Details panel, items becomes a table and notes closes the page. Templates and layout has the full rules.

result.content is the PDF as bytes. result.save(path) writes it to disk. In a web app you’ll usually return content directly.

Build the dict from your data

Keep the mapping in one function so both frameworks below can share it:

# documents.py
def packing_slip(order) -> dict:
    created = order.created_at
    return {
        "title": "Packing slip",
        "number": f"ORD-{order.id}",
        "date": f"{created.day} {created:%b %Y}",
        "from": {"name": "Hearth & Loom", "email": "[email protected]"},
        "to": {"name": order.customer_name, "address": order.shipping_address.splitlines()},
        "carrier": order.carrier,
        "items": [{"sku": line.sku, "item": line.name, "qty": line.quantity} for line in order.lines],
        "notes": "Thank you for your order. Returns are free within 30 days.",
    }

Format dates and labels here; Quire prints strings exactly as you send them. Key order is section order, so put the most important data first. Decimal values, such as Django’s DecimalField, are sent as numbers, so prices need no conversion.

FastAPI

# main.py
from fastapi import FastAPI, HTTPException, Response
from quirepdf import Quire, QuireError

from documents import packing_slip
from orders import get_order  # your database query

app = FastAPI()
quire = Quire()


@app.get("/orders/{order_id}/packing-slip.pdf")
def packing_slip_pdf(order_id: int) -> Response:
    order = get_order(order_id)
    if order is None:
        raise HTTPException(status_code=404, detail="Order not found")
    try:
        pdf = quire.render(packing_slip(order))
    except QuireError as err:
        raise HTTPException(status_code=502, detail={"type": err.type, "message": err.message, "fields": err.fields})
    return Response(
        content=pdf.content,
        media_type="application/pdf",
        headers={"Content-Disposition": f'inline; filename="packing-slip-{order_id}.pdf"'},
    )

Run it with fastapi dev main.py (installed by pip install "fastapi[standard]") and open http://127.0.0.1:8000/orders/88213/packing-slip.pdf.

The handler is a plain def, not async def. The SDK is synchronous, and FastAPI runs plain functions in a thread pool, so a render never blocks the event loop.

To turn whatever JSON a client posts into a PDF, accept a dict body:

from fastapi import Body


@app.post("/pdf")
def to_pdf(data: dict = Body()) -> Response:
    try:
        pdf = quire.render(data)
    except QuireError as err:
        raise HTTPException(status_code=422, detail={"type": err.type, "message": err.message, "fields": err.fields})
    return Response(content=pdf.content, media_type="application/pdf")

Django

# orders/views.py
from django.http import Http404, HttpResponse, JsonResponse
from quirepdf import Quire, QuireError

from .documents import packing_slip
from .models import Order

quire = Quire()


def packing_slip_pdf(request, order_id):
    try:
        order = Order.objects.get(pk=order_id)
    except Order.DoesNotExist:
        raise Http404("Order not found")
    try:
        pdf = quire.render(packing_slip(order))
    except QuireError as err:
        return JsonResponse({"type": err.type, "message": err.message, "fields": err.fields}, status=502)
    return HttpResponse(
        pdf.content,
        content_type="application/pdf",
        headers={"Content-Disposition": f'inline; filename="packing-slip-{order_id}.pdf"'},
    )
# orders/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path("orders/<int:order_id>/packing-slip.pdf", views.packing_slip_pdf),
]

If lines is a related manager in your model, use order.lines.all() in packing_slip. Add @login_required or your own permission check: the view shouldn’t hand out other customers’ orders.

Use a template when one fits

For invoices, receipts, quotes, credit notes, statements and certificates, Quire has purpose-built layouts that also compute totals. Use the generated TypedDict so your type checker catches missing fields:

from quirepdf import Quire
from quirepdf.types import InvoiceData

invoice: InvoiceData = {
    "number": "INV-2026-0142",
    "issued": "1 Oct 2026",
    "due": "15 Oct 2026",
    "seller": {"name": "Northwind Studio", "address": ["221 Market Street", "San Francisco, CA 94105"]},
    "customer": {"name": "Ravi Kumar", "address": ["14 Residency Road", "Bengaluru 560025"]},
    "items": [{"description": "Pro plan", "qty": 1, "unit_price": 49}],
}

pdf = Quire().render(invoice, template="invoice")

If your dict nearly matches a template, result.hint says what’s missing, for example invoice (seller is required).

Handle errors

When data doesn’t fit a named template, the API lists every problem, and the SDK raises QuireError:

data has 5 problems
  - issued is required
  - due is required
  - seller is required
  - customer is required
  - items is required

err.type is the machine-readable kind (invalid_data, unauthorized, quota_exceeded, …) and err.fields has each problem’s path and message. Failed renders don’t count against your quota, and quire.validate(data) checks data for free. See Errors.