How to Generate PDF Reports from HTML Templates in Python
You're building a web app. A user clicks "Download Invoice" and expects a professional PDF. You reach for wkhtmltopdf or weasyprint and... it works, but now you're managing a headless process. It crashes. It's slow. It ties up a worker thread.
There's a simpler pattern: render HTML → send to API → get PDF back.
Here's how to generate PDF reports from Jinja2 templates using a hosted PDF API.
The Problem: Self-Hosted PDF Generation Is Heavy
Self-hosted PDF libraries add complexity:
# Self-hosted wkhtmltopdf: process management overhead
from pdfkit import from_string
html_string = render_template('invoice.html', data=invoice_data)
pdf_bytes = from_string(html_string, False) # Spawns process, uses memory
What this costs:
- Memory: 50–150MB per PDF generation
- Time: 2–4 seconds per render
- Complexity: Shell escaping, process cleanup, error handling
- Reliability: Process can crash or hang
- Scalability: Can't generate 100 PDFs in parallel without spinning up 100 processes
If you're generating 1,000 PDFs/month (invoices, receipts, statements), self-hosted becomes expensive.
The Solution: PDF Generation API
One HTTP request. Binary PDF back. No process management.
import requests
import jinja2
# 1. Render your Jinja2 template to HTML string
template = jinja2.Template('''
<html>
<body style="font-family: Arial;">
<h1>Invoice #{{ invoice_number }}</h1>
<p>Date: {{ date }}</p>
<p>Amount: ${{ amount }}</p>
</body>
</html>
''')
html_string = template.render(invoice_number=12345, date='2026-03-24', amount=299.99)
# 2. Send HTML to PageBolt API
response = requests.post(
'https://api.pagebolt.dev/v1/pdf',
json={'html': html_string},
headers={'Authorization': 'Bearer YOUR_API_KEY'}
)
# 3. Save as file
with open('invoice.pdf', 'wb') as f:
f.write(response.content)
That's it. No process spawning. No timeouts. One API call.
Real Example: Invoice Generation in Django
Here's how to wire this into a Django view:
from django.http import FileResponse
from django.views import View
from django.template.loader import render_to_string
import requests
import io
class InvoiceDownloadView(View):
def get(self, request, invoice_id):
# Fetch invoice from database
invoice = Invoice.objects.get(id=invoice_id)
# Render Django template to HTML string
html_string = render_to_string('invoices/invoice_template.html', {
'invoice': invoice,
'company': Company.objects.first(),
'date': invoice.created_at.strftime('%B %d, %Y'),
})
# Send to PageBolt API
response = requests.post(
'https://api.pagebolt.dev/v1/pdf',
json={'html': html_string},
headers={'Authorization': f'Bearer {settings.PAGEBOLT_API_KEY}'}
)
# Return as downloadable file
pdf_io = io.BytesIO(response.content)
return FileResponse(
pdf_io,
as_attachment=True,
filename=f'invoice-{invoice_id}.pdf',
content_type='application/pdf'
)
Your Django template:
<!-- templates/invoices/invoice_template.html -->
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Helvetica', sans-serif; }
.invoice-header { text-align: center; margin-bottom: 40px; }
.invoice-number { font-size: 18px; font-weight: bold; }
table { width: 100%; border-collapse: collapse; }
td { padding: 8px; border-bottom: 1px solid #ddd; }
</style>
</head>
<body>
<div class="invoice-header">
<h1>{{ company.name }}</h1>
<p class="invoice-number">Invoice #{{ invoice.number }}</p>
<p>{{ date }}</p>
</div>
<table>
<tr>
<td>Item</td>
<td>Qty</td>
<td>Price</td>
</tr>
{% for line_item in invoice.items.all %}
<tr>
<td>{{ line_item.description }}</td>
<td>{{ line_item.quantity }}</td>
<td>${{ line_item.price }}</td>
</tr>
{% endfor %}
</table>
<div style="text-align: right; margin-top: 40px;">
<p><strong>Total: ${{ invoice.total }}</strong></p>
</div>
</body>
</html>
Then in your URL config:
# urls.py
from django.urls import path
from .views import InvoiceDownloadView
urlpatterns = [
path('invoices/<int:invoice_id>/download/', InvoiceDownloadView.as_view(), name='invoice_download'),
]
User visits /invoices/123/download/ → PDF downloads instantly.
Flask Example: Receipt Generation
Flask uses Jinja2 templates natively. Here's a receipt endpoint:
from flask import Flask, render_template_string, send_file
import requests
import io
app = Flask(__name__)
RECEIPT_TEMPLATE = '''
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: monospace; width: 300px; }
.receipt-header { text-align: center; margin-bottom: 20px; }
.receipt-item { display: flex; justify-content: space-between; padding: 5px 0; }
.receipt-total { border-top: 1px solid #000; margin-top: 10px; padding-top: 10px; font-weight: bold; }
</style>
</head>
<body>
<div class="receipt-header">
<h2>{{ business_name }}</h2>
<p>Receipt #{{ receipt_id }}</p>
<p>{{ timestamp }}</p>
</div>
{% for item in items %}
<div class="receipt-item">
<span>{{ item['name'] }}</span>
<span>${{ "%.2f"|format(item['price']) }}</span>
</div>
{% endfor %}
<div class="receipt-total">
<div style="display: flex; justify-content: space-between;">
<span>Total:</span>
<span>${{ "%.2f"|format(total) }}</span>
</div>
</div>
<p style="text-align: center; margin-top: 20px; font-size: 10px;">Thank you!</p>
</body>
</html>
'''
@app.route('/receipt/<receipt_id>')
def download_receipt(receipt_id):
# Render template with data
html_string = render_template_string(RECEIPT_TEMPLATE, {
'business_name': 'Coffee Shop',
'receipt_id': receipt_id,
'timestamp': '2026-03-24 10:30 AM',
'items': [
{'name': 'Espresso', 'price': 3.50},
{'name': 'Croissant', 'price': 4.00},
],
'total': 7.50
})
# Call PageBolt API
response = requests.post(
'https://api.pagebolt.dev/v1/pdf',
json={'html': html_string},
headers={'Authorization': f'Bearer {app.config["PAGEBOLT_API_KEY"]}'}
)
return send_file(
io.BytesIO(response.content),
as_attachment=True,
download_name=f'receipt-{receipt_id}.pdf',
mimetype='application/pdf'
)
if __name__ == '__main__':
app.config['PAGEBOLT_API_KEY'] = 'YOUR_API_KEY'
app.run(debug=True)
User visits /receipt/789 → PDF receipt downloads.
Batch Report Generation
Need to generate 100 invoices? Use Python's requests.Session for connection pooling:
import requests
from concurrent.futures import ThreadPoolExecutor
def generate_pdf(invoice_id, html_string):
"""Generate one PDF via API."""
session = requests.Session()
session.headers.update({
'Authorization': f'Bearer {PAGEBOLT_API_KEY}'
})
response = session.post(
'https://api.pagebolt.dev/v1/pdf',
json={'html': html_string},
timeout=30
)
if response.status_code == 200:
with open(f'invoices/{invoice_id}.pdf', 'wb') as f:
f.write(response.content)
print(f'✓ Generated invoice {invoice_id}')
else:
print(f'✗ Failed invoice {invoice_id}: {response.status_code}')
# Generate 100 invoices in parallel
invoices = Invoice.objects.all()[:100]
with ThreadPoolExecutor(max_workers=10) as executor:
for invoice in invoices:
html = render_to_string('invoices/template.html', {'invoice': invoice})
executor.submit(generate_pdf, invoice.id, html)
This runs 10 PDFs at a time. Total time: ~10 seconds for 100 invoices.
Cost Breakdown: When to Use the API
| Scenario | Self-Hosted | PageBolt API |
|---|---|---|
| 10 PDFs/month | $0 | $0 (free tier) |
| 100 PDFs/month | $0 + infra | $3–5 (Starter) |
| 1,000 PDFs/month | $0 + CPU spike | $15–25 (Growth) |
| 10,000+ PDFs/month | Server + ops | $59+ (Scale) |
Key insight: If you're generating >50 PDFs/month, the API is cheaper than the CPU time self-hosting costs.
Error Handling
import requests
from requests.exceptions import RequestException, Timeout
def generate_pdf_safe(html_string, output_path, retries=3):
"""Generate PDF with retry logic."""
for attempt in range(retries):
try:
response = requests.post(
'https://api.pagebolt.dev/v1/pdf',
json={'html': html_string},
headers={'Authorization': f'Bearer {PAGEBOLT_API_KEY}'},
timeout=30
)
if response.status_code == 200:
with open(output_path, 'wb') as f:
f.write(response.content)
return True
elif response.status_code == 429:
# Rate limited — wait and retry
time.sleep(2 ** attempt)
continue
else:
print(f'API error {response.status_code}: {response.text}')
return False
except Timeout:
print(f'Timeout on attempt {attempt + 1}')
time.sleep(2)
except RequestException as e:
print(f'Request error: {e}')
return False
return False
Key Takeaways
- Render HTML first — Use Jinja2, Django templates, or any templating engine
- Send to API — One POST request with the HTML string
- Save the PDF — Write response.content to disk
- Batch safely — Use ThreadPoolExecutor with 5–10 workers to generate many PDFs in parallel
- Handle errors — Retry on timeout, rate-limit, and network errors
Next step: Get your free API key at pagebolt.dev. 100 requests/month, no credit card required.
Try it free: https://pagebolt.dev/pricing
Top comments (0)