Python has no shortage of PDF libraries. That is actually the problem.
You start with ReportLab because it is the most established. Then you spend two days learning its coordinate-based layout system, placing text at exact pixel positions like it is 2003. You switch to WeasyPrint because it supports HTML and CSS. Then you fight Cairo dependency errors on your deployment server. You try wkhtmltopdf because someone on Stack Overflow recommended it. Then you discover it has not been meaningfully updated in years and struggles with modern CSS.
Every option works locally. Every option breaks somewhere in production.
There is a simpler path: skip the local rendering entirely and generate PDFs with an API call.
The Problem: Python PDF Libraries Are Painful
Let us be specific about what goes wrong with each approach.
ReportLab
ReportLab is powerful but low-level. You build PDFs by placing elements at coordinates:
from reportlab.pdfgen import canvas
c = canvas.Canvas("output.pdf")
c.drawString(72, 720, "Invoice #1234")
c.drawString(72, 700, "Customer: Acme Corp")
c.drawString(72, 680, "Amount: $500.00")
c.save()
For anything more complex than a few lines of text, you need Platypus (ReportLab's higher-level layout engine), which has its own learning curve. Changing the visual design means changing Python code. Designers cannot touch the template.
WeasyPrint
WeasyPrint takes HTML and CSS as input, which is a much better developer experience. The problem is dependencies:
# On Ubuntu/Debian
apt-get install libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev libcairo2
# On macOS
brew install pango libffi cairo gdk-pixbuf
# On Alpine (Docker)
apk add pango cairo gdk-pixbuf py3-cffi
Miss one system dependency and you get a cryptic error at runtime. Docker builds get bloated. Alpine images need extra packages. And if you are deploying to a serverless platform, good luck installing Cairo.
wkhtmltopdf
wkhtmltopdf uses an old WebKit engine. It does not support CSS Grid, Flexbox is unreliable, and modern CSS features are hit-or-miss. It also requires a binary install:
# Another binary to manage in production
apt-get install wkhtmltopdf
The project is largely unmaintained. You are betting your document generation on software that cannot render the CSS you write today.
Why an API Approach Works Better
Instead of managing a rendering engine in your Python environment, you send data to an API and get a PDF URL back. Your Python code stays simple:
import requests
response = requests.post(
"https://api.transactional.dev/v1/generate",
headers={
"Content-Type": "application/json",
"x-api-token": TRANSACTIONAL_API_KEY,
},
json={
"documentId": "your-template-uuid",
"variables": {
"customer_name": "Acme Corp",
"invoice_number": "INV-2025-001",
"total": "$1,250.00",
},
},
)
pdf_url = response.json()["url"]
No system dependencies. No binary installs. No Docker build complications. Just a simple HTTP call.
Transactional.dev handles the rendering on dedicated infrastructure. You design your template once with HTML and Tailwind CSS, then generate PDFs by passing variables through the API.
Django Example
Here is a complete Django view that generates and returns a PDF:
import requests
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required
@login_required
@require_POST
def generate_invoice(request, order_id):
order = Order.objects.select_related("customer").get(
id=order_id, customer__user=request.user
)
items = order.items.all()
resp = requests.post(
"https://api.transactional.dev/v1/generate",
headers={
"Content-Type": "application/json",
"x-api-token": settings.TRANSACTIONAL_API_KEY,
},
json={
"documentId": settings.INVOICE_TEMPLATE_ID,
"variables": {
"customer_name": order.customer.name,
"customer_email": order.customer.email,
"invoice_number": order.invoice_number,
"invoice_date": order.created_at.strftime("%B %d, %Y"),
"line_items": [
{
"description": item.description,
"quantity": item.quantity,
"unit_price": f"${item.unit_price:.2f}",
"total": f"${item.total:.2f}",
}
for item in items
],
"subtotal": f"${order.subtotal:.2f}",
"tax": f"${order.tax:.2f}",
"total": f"${order.total:.2f}",
},
},
timeout=30,
)
resp.raise_for_status()
return JsonResponse({"pdf_url": resp.json()["url"]})
In settings.py:
TRANSACTIONAL_API_KEY = env("TRANSACTIONAL_API_KEY")
INVOICE_TEMPLATE_ID = env("INVOICE_TEMPLATE_ID")
Flask Example
import os
import requests
from flask import Flask, request, jsonify
from flask_login import login_required, current_user
app = Flask(__name__)
@app.route("/api/generate-report", methods=["POST"])
@login_required
def generate_report():
data = request.get_json()
resp = requests.post(
"https://api.transactional.dev/v1/generate",
headers={
"Content-Type": "application/json",
"x-api-token": os.environ["TRANSACTIONAL_API_KEY"],
},
json={
"documentId": os.environ["REPORT_TEMPLATE_ID"],
"variables": {
"report_title": data["title"],
"period": data["period"],
"generated_by": current_user.name,
"sections": data["sections"],
},
},
timeout=30,
)
resp.raise_for_status()
return jsonify({"pdf_url": resp.json()["url"]})
FastAPI Example
import os
import httpx
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class InvoiceRequest(BaseModel):
customer_name: str
customer_email: str
items: list[dict]
total: str
@app.post("/api/generate-invoice")
async def generate_invoice(data: InvoiceRequest):
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://api.transactional.dev/v1/generate",
headers={
"Content-Type": "application/json",
"x-api-token": os.environ["TRANSACTIONAL_API_KEY"],
},
json={
"documentId": os.environ["INVOICE_TEMPLATE_ID"],
"variables": {
"customer_name": data.customer_name,
"customer_email": data.customer_email,
"line_items": data.items,
"total": data.total,
},
},
timeout=30.0,
)
resp.raise_for_status()
return {"pdf_url": resp.json()["url"]}
Note: FastAPI is async, so we use httpx with AsyncClient instead of requests to avoid blocking the event loop.
Helper Function for Reuse
Wrap the API call in a reusable function:
import os
import requests
def generate_pdf(template_id: str, variables: dict) -> str:
"""Generate a PDF and return the URL."""
resp = requests.post(
"https://api.transactional.dev/v1/generate",
headers={
"Content-Type": "application/json",
"x-api-token": os.environ["TRANSACTIONAL_API_KEY"],
},
json={
"documentId": template_id,
"variables": variables,
},
timeout=30,
)
resp.raise_for_status()
return resp.json()["url"]
Then use it anywhere:
from services.pdf import generate_pdf
pdf_url = generate_pdf(
template_id="your-invoice-template-uuid",
variables={
"customer_name": "Acme Corp",
"total": "$500.00",
},
)
Implementation Notes
Use environment variables for keys: Never hardcode your API key. Use os.environ or a settings module.
Set a timeout: Always pass timeout=30 (or similar) to your HTTP client. Without it, a network issue can hang your request indefinitely.
Handle errors properly: Check the response status and wrap calls in try/except. Return meaningful error messages to your users.
Use httpx for async code: If you are using FastAPI or any async framework, use httpx.AsyncClient instead of requests to avoid blocking.
Common Mistakes
Installing WeasyPrint "just for one PDF": The dependency chain is not worth it for a single document type. If you are generating more than basic text, an API approach saves hours of setup and maintenance.
Using requests in async code: Calling requests.post() in a FastAPI endpoint blocks the event loop. Use httpx with await instead.
Not validating input data: Sanitize variables before sending them to the API. Empty strings, None values, and missing fields will produce broken PDFs.
Forgetting to handle large responses: If you are proxying the PDF back to the user (instead of redirecting), stream the response to avoid loading the entire PDF into memory.
Conclusion
Python PDF generation does not require fighting with system dependencies, low-level layout APIs, or unmaintained binaries. A single HTTP call replaces hundreds of lines of ReportLab code or a fragile WeasyPrint installation.
Design your template with HTML and Tailwind in Transactional.dev, then call the API from Django, Flask, FastAPI, or any Python framework. The rendering happens elsewhere. Your Python code stays clean.
Get started with Transactional.dev and generate your first PDF from Python in minutes.



