DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to Generate PDF Reports from HTML Templates in Python

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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'
        )
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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'),
]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)