VOOZH about

URL: https://dev.to/custodiaadmin/how-to-generate-pdf-reports-from-html-templates-in-python-3c05

⇱ How to Generate PDF Reports from HTML Templates in Python - DEV Community


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://pagebolt.dev/api/v1/pdf',
 json={'html': html_string},
 headers={'x-api-key': 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://pagebolt.dev/api/v1/pdf',
 json={'html': html_string},
 headers={'x-api-key': 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://pagebolt.dev/api/v1/pdf',
 json={'html': html_string},
 headers={'x-api-key': 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({
 'x-api-key': PAGEBOLT_API_KEY
 })

 response = session.post(
 'https://pagebolt.dev/api/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://pagebolt.dev/api/v1/pdf',
 json={'html': html_string},
 headers={'x-api-key': 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

  1. Render HTML first — Use Jinja2, Django templates, or any templating engine
  2. Send to API — One POST request with the HTML string
  3. Save the PDF — Write response.content to disk
  4. Batch safely — Use ThreadPoolExecutor with 5–10 workers to generate many PDFs in parallel
  5. 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