DEV Community

Custodia-Admin
Custodia-Admin

Posted on Edited 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://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)
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://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'
        )
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://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)
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({
        '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)
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://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
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)