HTML to PDF in Flask and FastAPI

How to return a PDF from a Flask or FastAPI endpoint: render a Jinja2 template to HTML, convert it, and send it back as a download. Three converters, each run as a real app on the same invoice template: WeasyPrint 67, Playwright 1.64 with Chromium, and an HTTP API. Tested with Flask 3.1 and FastAPI 0.150.

Quick answer

ConverterPick it when
WeasyPrint
Python library, no browser
Invoices, receipts, reports you design yourself. Good print CSS (page numbers, margins), flexbox and grid. No JavaScript.
Playwright
Headless Chromium
Templates that need JavaScript (charts) or must match Chrome exactly. You run a browser next to your app.
An API
Chromium on someone else's server
Chrome output without a browser on your host: PaaS dynos, small containers, serverless.

The template

Save as templates/invoice.html, the folder both Flask and FastAPI's Jinja2Templates read from. The @page rule sets A4 and prints "Page 1 of 1" in the corner. In the code below, get_invoice(pk) stands for your own database lookup, returning a dict or object with number, due and lines.

<!doctype html>
<html><head><meta charset="utf-8"><style>
  @page { size: A4; margin: 2cm; @bottom-right { content: "Page " counter(page) " of " counter(pages); } }
  body { font-family: sans-serif; }
  .top { display: flex; justify-content: space-between; }
  table { width: 100%; border-collapse: collapse; }
  td, th { border-bottom: 1px solid #ddd; padding: 6px; text-align: left; }
</style></head><body>
  <div class="top"><h1>Invoice #{{ invoice.number }}</h1><p>Due: {{ invoice.due }}</p></div>
  <table><tr><th>Item</th><th>Price</th></tr>
    {% for line in invoice.lines %}<tr><td>{{ line.item }}</td><td>€{{ line.price }}</td></tr>{% endfor %}
  </table>
</body></html>

Flask

1. WeasyPrint

pip install flask weasyprint

On Linux WeasyPrint also needs Pango (apt install weasyprint pulls everything in on Debian/Ubuntu; in a python:3 Docker image, apt-get install -y libpango-1.0-0 libpangoft2-1.0-0). On macOS, brew install weasyprint.

from flask import Flask, render_template, make_response, request
from weasyprint import HTML

app = Flask(__name__)

@app.get("/invoices/<int:pk>.pdf")
def invoice_pdf(pk):
    invoice = get_invoice(pk)
    html = render_template("invoice.html", invoice=invoice)
    pdf = HTML(string=html, base_url=request.url_root).write_pdf()
    response = make_response(pdf)
    response.headers["Content-Type"] = "application/pdf"
    response.headers["Content-Disposition"] = f'attachment; filename="invoice-{pk}.pdf"'
    return response

Result: GET /invoices/7.pdf downloads invoice-7.pdf, a one-page A4 with the title and due date side by side and "Page 1 of 1" at the bottom right. Change attachment to inline to open it in the browser's PDF viewer instead. base_url=request.url_root lets WeasyPrint resolve {{ url_for('static', filename='logo.png') }} and other relative URLs by fetching them from your own app over HTTP; that works when the server handles more than one request at a time (the dev server does), but for production prefer absolute file:// paths or data URIs for images.

2. Playwright (headless Chromium)

pip install flask playwright
playwright install chromium
from flask import Flask, render_template, Response
from playwright.sync_api import sync_playwright

app = Flask(__name__)

@app.get("/invoices/<int:pk>.pdf")
def invoice_pdf(pk):
    html = render_template("invoice.html", invoice=get_invoice(pk))
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.set_content(html, wait_until="networkidle")
        pdf = page.pdf(format="A4", print_background=True, prefer_css_page_size=True)
        browser.close()
    return Response(pdf, mimetype="application/pdf",
                    headers={"Content-Disposition": f'attachment; filename="invoice-{pk}.pdf"'})

Same PDF, same "Page 1 of 1": Chromium supports @page margin boxes, and prefer_css_page_size=True lets your CSS set size and margins. print_background=True is off by default; without it, background colors and images disappear. Launching Chromium costs about a second per request, so under load keep one browser per worker or render in a task queue (Celery, RQ). On Ubuntu 23.10+ and in many containers, Chromium can fail to start because of sandbox restrictions; p.chromium.launch(args=["--no-sandbox"]) fixes it in a trusted environment.

3. An API (no browser on your server)

Shotmint runs the same Chromium print path, so the output matches Playwright, while your Flask process only makes an HTTP call. Put your key in the SHOTMINT_KEY environment variable:

import os
import requests
from flask import Flask, render_template, Response

app = Flask(__name__)

@app.get("/invoices/<int:pk>.pdf")
def invoice_pdf(pk):
    html = render_template("invoice.html", invoice=get_invoice(pk))
    r = requests.post("https://shotmint.creasework.com/v1/pdf",
                      headers={"X-API-Key": os.environ["SHOTMINT_KEY"]},
                      json={"html": html, "format": "A4", "margin": "2cm"},
                      timeout=60)
    r.raise_for_status()
    return Response(r.content, mimetype="application/pdf",
                    headers={"Content-Disposition": f'attachment; filename="invoice-{pk}.pdf"'})

The HTML travels in the request body, so images and fonts must be reachable from the internet (absolute https:// URLs) or inlined. Other options: landscape, format (A3, A4, A5, Letter, Legal, Tabloid), scale, print_background (on by default). For a public page, send "url": "https://..." instead of html. Errors come back as JSON with a 4xx status, which raise_for_status() turns into an exception.

FastAPI

The converters are the same; what changes is sync versus async. WeasyPrint and requests block, so put them in a plain def endpoint (FastAPI runs those in a threadpool) or they freeze the event loop for every other request. Playwright and httpx have async APIs, so those go in async def endpoints. One file with all three:

pip install fastapi uvicorn jinja2 weasyprint playwright httpx
import os
import httpx
from fastapi import FastAPI, Request, Response
from fastapi.templating import Jinja2Templates
from playwright.async_api import async_playwright
from weasyprint import HTML

app = FastAPI()
templates = Jinja2Templates(directory="templates")

def pdf_response(pdf: bytes, pk: int) -> Response:
    return Response(pdf, media_type="application/pdf",
                    headers={"Content-Disposition": f'attachment; filename="invoice-{pk}.pdf"'})

def render_html(pk: int) -> str:
    return templates.get_template("invoice.html").render(invoice=get_invoice(pk))

# WeasyPrint is synchronous: a plain "def" endpoint runs in FastAPI's threadpool
@app.get("/weasyprint/{pk}.pdf")
def invoice_weasyprint(pk: int, request: Request):
    pdf = HTML(string=render_html(pk), base_url=str(request.base_url)).write_pdf()
    return pdf_response(pdf, pk)

# Playwright has an async API: use it in "async def" endpoints
@app.get("/playwright/{pk}.pdf")
async def invoice_playwright(pk: int):
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()
        await page.set_content(render_html(pk), wait_until="networkidle")
        pdf = await page.pdf(format="A4", print_background=True, prefer_css_page_size=True)
        await browser.close()
    return pdf_response(pdf, pk)

# An API call with httpx.AsyncClient doesn't block the event loop
@app.get("/api/{pk}.pdf")
async def invoice_api(pk: int):
    async with httpx.AsyncClient(timeout=60) as client:
        r = await client.post("https://shotmint.creasework.com/v1/pdf",
                              headers={"X-API-Key": os.environ["SHOTMINT_KEY"]},
                              json={"html": render_html(pk), "format": "A4", "margin": "2cm"})
    r.raise_for_status()
    return pdf_response(r.content, pk)

Run it with uvicorn fastapi_app:app; /weasyprint/9.pdf, /playwright/9.pdf and /api/9.pdf each return the same one-page A4 invoice. Don't call playwright.sync_api inside an async def endpoint: it raises "It looks like you are using Playwright Sync API inside the asyncio loop".

Common errors

ErrorFix
OSError: cannot load library 'libgobject-2.0-0'WeasyPrint can't find Pango/GLib. Install the system packages above (on Windows, the GTK runtime).
jinja2.exceptions.TemplateNotFoundThe template isn't in templates/ next to your app (Flask) or the directory you gave Jinja2Templates.
Logo or CSS missing in the PDFRelative URL with no base_url. Pass base_url, or use absolute URLs.
Playwright "Sync API inside the asyncio loop"FastAPI async def endpoint: use async_playwright.
Colors gone (Playwright)Add print_background=True.
File opens as text, or named after the URLSet the application/pdf type and a Content-Disposition filename.

Which one?

For documents you design yourself, start with WeasyPrint: no browser, good print CSS, a few lines of code. Switch to Playwright if the template needs JavaScript or must look exactly like Chrome. Use the API if you want Chrome output but can't or don't want to run Chromium on your host. Want to check your template first? Paste the rendered HTML into the free HTML to PDF converter; no key needed. Django: HTML to PDF in Django (incl. email attachments). Plain scripts and xhtml2pdf: HTML to PDF in Python.

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons

Try it with 100 free renders

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire).

Get a free API key Full API reference