Generating PDFs from HTML in Python has always been more annoying than it should be. There's no built-in solution, the libraries have frustrating limitations, and getting consistent rendering across environments takes real effort.
This guide covers every practical approach in 2026, with working code examples for each. Skip to the verdict if you want the short answer.
Method 1: WeasyPrint
Pros
- Pure Python, no Chromium
- Good CSS Paged Media support
- Active maintenance
- Header/footer via CSS
Cons
- No JavaScript execution
- System deps (Pango, Cairo, GDK-PixBuf)
- Complex CSS sometimes breaks
- Flexbox support is incomplete
pip install weasyprint
from weasyprint import HTML, CSS
# From HTML string
html_content = """
Invoice #1234
April 4, 2026
Item Amount
SnapAPI Pro Plan $79.00
"""
# To file
HTML(string=html_content).write_pdf('/tmp/invoice.pdf')
# To bytes (for HTTP response)
pdf_bytes = HTML(string=html_content).write_pdf()
# From URL
HTML(url='https://yourapp.com/invoice/123').write_pdf('/tmp/invoice.pdf')
# With base URL (resolves relative links correctly)
HTML(string=html_content, base_url='https://yourapp.com').write_pdf('/tmp/invoice.pdf')
WeasyPrint is ideal for document-style PDFs — invoices, contracts, reports — where you control the HTML and don't need JavaScript. It implements CSS Paged Media properly, which means @page, page-break-before, and print-specific CSS actually work. Where it struggles: CSS Grid (partial support), JavaScript-rendered content (none), and complex visual designs that depend on modern CSS.
Method 2: Playwright (Recommended for JS-heavy apps)
Pros
- Real browser — all CSS works
- Full JavaScript rendering
- Handles React/Vue/Next.js apps
- Actively maintained by Microsoft
Cons
- ~600MB browser download on install
- 150-200MB memory per browser instance
- Async only (requires asyncio)
- Cold start latency (1-3s)
pip install playwright
python -m playwright install chromium
import asyncio
from playwright.async_api import async_playwright
async def html_to_pdf(url: str, output_path: str):
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
# Navigate and wait for network to settle
await page.goto(url, wait_until='networkidle')
# Also wait for fonts to render
await page.evaluate("document.fonts.ready")
pdf_bytes = await page.pdf(
format='A4',
print_background=True,
margin={
'top': '20mm',
'bottom': '20mm',
'left': '15mm',
'right': '15mm'
}
)
await browser.close()
return pdf_bytes
# Run it
async def main():
pdf = await html_to_pdf('https://yourapp.com/invoice/123', '/tmp/invoice.pdf')
with open('/tmp/invoice.pdf', 'wb') as f:
f.write(pdf)
asyncio.run(main())
Playwright in a web framework (sync wrapper)
from playwright.sync_api import sync_playwright
def generate_pdf_sync(url: str) -> bytes:
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto(url, wait_until='networkidle')
pdf = page.pdf(
format='A4',
print_background=True,
margin={'top': '20mm', 'bottom': '20mm', 'left': '15mm', 'right': '15mm'}
)
browser.close()
return pdf
Playwright is the most reliable self-hosted option for 2026. It handles everything — React apps, custom fonts, CSS Grid, modern animations, lazy-loaded images. The cost is the Chromium binary and memory overhead per request. For low-to-medium volume (under ~500 PDFs/day), the setup cost is worth it.
Method 3: Pyppeteer
Pros
- Familiar if you know Puppeteer
- Real Chromium rendering
- Full JavaScript support
Cons
- Unofficial port — not maintained by Google
- Lags behind Puppeteer features
- Playwright is better in every way
- Same memory issues as Playwright
pip install pyppeteer
import asyncio
from pyppeteer import launch
async def generate_pdf(url: str) -> bytes:
browser = await launch(headless=True)
page = await browser.newPage()
await page.goto(url, {'waitUntil': 'networkidle0'})
pdf = await page.pdf({
'format': 'A4',
'printBackground': True,
'margin': {'top': '20mm', 'bottom': '20mm', 'left': '15mm', 'right': '15mm'}
})
await browser.close()
return pdf
pdf_bytes = asyncio.run(generate_pdf('https://yourapp.com/invoice/123'))
Skip pyppeteer in 2026. It's an unofficial Python port of Puppeteer that lags behind, has maintenance gaps, and Playwright exists. If you need browser-based PDF generation in Python, use Playwright instead.
Method 4: PDF API (No Browser on Your Server)
Pros
- Zero browser binary on your server
- Works synchronously — no asyncio required
- No memory management
- Scales automatically
Cons
- Network latency (~200-600ms)
- URL must be publicly accessible
- Costs money at volume
pip install httpx # or requests
import httpx
def generate_pdf_from_url(url: str, api_key: str) -> str:
"""Returns a CDN URL to the generated PDF."""
response = httpx.post(
'https://api.snapapi.pics/v1/screenshot',
headers={'X-Api-Key': api_key},
json={
'url': url,
'format': 'pdf',
'pdf_format': 'A4',
'pdf_print_background': True,
'pdf_margin_top': '20mm',
'pdf_margin_bottom': '20mm',
'pdf_margin_left': '15mm',
'pdf_margin_right': '15mm',
},
timeout=30.0
)
response.raise_for_status()
return response.json()['url'] # CDN URL valid for 24h
# Usage
pdf_url = generate_pdf_from_url(
'https://yourapp.com/invoice/123?token=secure_token',
api_key='sk_live_your_key'
)
# Redirect user to pdf_url or download it
# Download the PDF bytes if you need them locally
import httpx
def generate_pdf_bytes(url: str, api_key: str) -> bytes:
api_response = httpx.post(
'https://api.snapapi.pics/v1/screenshot',
headers={'X-Api-Key': api_key},
json={'url': url, 'format': 'pdf', 'pdf_format': 'A4', 'pdf_print_background': True},
timeout=30.0
)
api_response.raise_for_status()
pdf_url = api_response.json()['url']
# Download the actual PDF
pdf_response = httpx.get(pdf_url)
return pdf_response.content
Django Integration
# views.py — return a PDF as HTTP response
import httpx
from django.http import HttpResponse, Http404
from django.contrib.auth.decorators import login_required
@login_required
def download_invoice_pdf(request, invoice_id):
try:
invoice = Invoice.objects.get(id=invoice_id, user=request.user)
except Invoice.DoesNotExist:
raise Http404
# Generate a signed URL to your invoice render view
signed_url = request.build_absolute_uri(f'/invoices/{invoice_id}/render/?token={invoice.pdf_token}')
# Call the PDF API
resp = httpx.post(
'https://api.snapapi.pics/v1/screenshot',
headers={'X-Api-Key': 'sk_live_xxx'},
json={'url': signed_url, 'format': 'pdf', 'pdf_format': 'A4', 'pdf_print_background': True},
timeout=30
)
resp.raise_for_status()
pdf_url = resp.json()['url']
# Option A: redirect to CDN URL
return HttpResponseRedirect(pdf_url)
# Option B: proxy the bytes
# pdf_bytes = httpx.get(pdf_url).content
# return HttpResponse(pdf_bytes, content_type='application/pdf',
# headers={'Content-Disposition': f'attachment; filename="invoice-{invoice_id}.pdf"'})
FastAPI Integration
from fastapi import FastAPI, Depends, HTTPException
from fastapi.responses import RedirectResponse
import httpx
app = FastAPI()
@app.get('/invoices/{invoice_id}/pdf')
async def get_invoice_pdf(invoice_id: int, token: str):
# Validate token against your DB...
invoice_url = f'https://yourapp.com/invoices/{invoice_id}/render?token={token}'
async with httpx.AsyncClient() as client:
resp = await client.post(
'https://api.snapapi.pics/v1/screenshot',
headers={'X-Api-Key': 'sk_live_xxx'},
json={
'url': invoice_url,
'format': 'pdf',
'pdf_format': 'A4',
'pdf_print_background': True,
},
timeout=30.0
)
if resp.status_code != 200:
raise HTTPException(status_code=500, detail='PDF generation failed')
pdf_url = resp.json()['url']
return RedirectResponse(url=pdf_url)
Comparison Table
| Method | JS support | Setup | Memory | Serverless | Cost |
|---|---|---|---|---|---|
| WeasyPrint | None | System libs | Low | Limited | Free |
| Playwright | Full | npm + Chromium | 150-200MB/browser | Complex | Free + infra |
| Pyppeteer | Full | pip + Chromium | 150-200MB/browser | Complex | Free + infra |
| SnapAPI | Full | pip install httpx |
Zero | Yes | Free tier → $19/mo |
Which Method Should You Use?
Static HTML with no JS (invoices, contracts, reports): Start with WeasyPrint. It's pure Python, has no browser overhead, and handles document-style PDFs well. Add CSS Paged Media for headers/footers and page numbers.
React/Vue/Next.js apps or JS-required rendering: Use Playwright. It's the best self-hosted option with full modern browser support. Budget ~200MB memory per concurrent PDF and handle cold starts gracefully.
Serverless (Lambda, Cloud Run, Fly.io) or low-ops preference: Use a PDF API. The Chromium binary is too large for most serverless environments, and the cold start penalty is brutal. One HTTP call from Python is simpler, more reliable, and often cheaper when you factor in compute time.
High volume (1,000+ PDFs/day): Run the numbers. At 50,000 PDFs/month, SnapAPI's Pro tier ($79/mo) is likely cheaper than the compute cost of running Playwright servers, including memory overhead, autoscaling, and engineering time for browser crash handling.
Generate PDFs from Python with one HTTP call
200 free PDFs/month, no credit card. Full JS rendering, modern CSS, works with Django, FastAPI, Flask.
Get your free API key →