HTML to PDF in Python: Django, Flask and FastAPI Covered
Blog / HTML to PDF
HTML to PDF

HTML to PDF in Python: Django, Flask and FastAPI Covered

Demetra Theodorou · Oct 21, 2026

Generate PDFs from HTML and Jinja templates in Python and serve them from Django, Flask or FastAPI. Page setup, headers, async calls and error handling, all run before publishing.

In Python the HTML to PDF question usually comes up inside a Flask or Django app, or in a script that has to turn a report into something a customer can open. This tutorial covers that ground without installing a rendering engine: one URL conversion, page setup, running headers, a Jinja template fed from a dictionary, pages that need JavaScript to finish, error handling, and a Flask application that serves invoices.

The HTML to PDF API does the rendering with Chrome on PDFGate's side, so the Python process only makes HTTP calls. All of the code on this page was executed against the API before it was published, and the Flask routes were started and hit with curl.

Key takeaways

  • Two calls do the work: generate_pdf renders, get_file returns the bytes. Nothing native to install, unlike WeasyPrint or Playwright.
  • Parameters are dataclasses. Set page_size_type explicitly, the default is Ledger, and pass margins as a PdfPageMargin.
  • Jinja with autoescape=True turns your data into the HTML, the same engine Flask already uses for pages.
  • In Flask return the bytes with send_file, in Django an HttpResponse, and in FastAPI await the _async twins of every method.

The options in Python

Python has a long list of HTML to PDF packages, and each one trades something away:

  • WeasyPrint. Excellent CSS support for print, including paged media, but no JavaScript at all, and it needs Pango and Cairo installed on the machine, which is where most deployment trouble starts.
  • pdfkit and xhtml2pdf. pdfkit wraps wkhtmltopdf, whose engine was archived in 2023, so flexbox and grid are unreliable. xhtml2pdf is pure Python but supports a small subset of CSS.
  • Playwright or pyppeteer. Real Chrome output, but you download a browser of a few hundred megabytes onto every server or into every container image and give it memory.
  • A conversion API. No Cairo, no Pango, no Chromium download, and the PDF is what Chrome prints. The price is one HTTP request per document and a plan.

From here on the API is used. If the choice is between that and hosting Chrome yourself, the Puppeteer comparison sets out the differences.

Install the Python SDK

The pdfgate distribution on PyPI supports Python 3.9 and later and depends on httpx alone. The template section adds Jinja2, which Flask installs anyway.

1pip install pdfgate

Generate a key in the dashboard. A key beginning with test_ is routed to the sandbox, whose PDFs carry a watermark and cost nothing, and one beginning with live_ is routed to production. The listings read the key from os.environ.

Convert a URL to PDF

One conversion is a generate_pdf call followed by get_file. The first returns a dictionary describing the stored document, the second returns its bytes.

1import os23from pdfgate import GeneratePDFParams, GetFileParams, PageSizeType, PDFGate45client = PDFGate(os.environ["PDFGATE_API_KEY"])67page = client.generate_pdf(8    GeneratePDFParams(9        url="https://en.wikipedia.org/wiki/Portable_Document_Format",10        page_size_type=PageSizeType.A4,11    )12)1314pdf = client.get_file(GetFileParams(document_id=page["id"]))15with open("wikipedia.pdf", "wb") as f:16    f.write(pdf)1718print(f"URL -> {page['id']} ({page['size']} bytes)")
  • Parameters are dataclasses rather than keyword arguments, so autocomplete works and a mistyped name is a TypeError before any network traffic.
  • The renderer needs a URL it can reach from the internet. localhost and private addresses are refused with a 400.
  • Leave page_size_type unset and you get Ledger. PageSizeType has members for A0 to A6, LETTER, LEGAL, LEDGER and TABLOID, with width and height in pixels for anything else.
  • The response keys are snake_case. page["size"] is the byte count of the PDF, roughly 1.4 MB for the 23 page Wikipedia article.
  • One PDFGate instance per process is enough, and each method has an _async counterpart for asyncio code.

Convert an HTML string

If the document comes from your own code, supply html rather than url. A triple-quoted string carries a full HTML page including its <style> block, and any public stylesheets, images or fonts it references are fetched while rendering.

1from pdfgate import PdfPageMargin23invoice_html = """4<!DOCTYPE html>5<html>6<head>7  <meta charset="utf-8">8  <style>9    body { font-family: Helvetica, Arial, sans-serif; color: #1f2933; }10    h1 { color: #0055d4; }11    table { width: 100%; border-collapse: collapse; margin-top: 24px; }12    th { background: #0055d4; color: white; text-align: left; padding: 8px; }13    td { padding: 8px; border-bottom: 1px solid #e4e7eb; }14    .total { text-align: right; font-size: 18px; margin-top: 16px; }15  </style>16</head>17<body>18  <h1>Invoice #2026-0142</h1>19  <p>Billed to Acme GmbH, 21 October 2026</p>20  <table>21    <tr><th>Item</th><th>Qty</th><th>Price</th></tr>22    <tr><td>API plan, Business</td><td>1</td><td>EUR 79.00</td></tr>23    <tr><td>Extra documents</td><td>2,000</td><td>EUR 20.00</td></tr>24  </table>25  <p class="total">Total EUR 99.00</p>26</body>27</html>28"""2930invoice = client.generate_pdf(31    GeneratePDFParams(32        html=invoice_html,33        page_size_type=PageSizeType.A4,34        margin=PdfPageMargin(top="20mm", bottom="20mm", left="15mm", right="15mm"),35        print_background=True,36    )37)3839with open("invoice.pdf", "wb") as f:40    f.write(client.get_file(GetFileParams(document_id=invoice["id"])))4142print(f"HTML -> {invoice['id']} ({invoice['size']} bytes)")
  • PdfPageMargin is a dataclass with top, bottom, left and right as CSS length strings. Omit a field to keep that side's default.
  • Coloured backgrounds disappear in print mode unless print_background=True is passed. A white table header is the usual symptom.
  • orientation=FileOrientation.LANDSCAPE rotates the page, and emulate_media_type=EmulateMediaType.SCREEN renders the page's screen styles instead of its print styles.
  • The leading whitespace that a triple-quoted string picks up from indentation does not affect the render.

The remaining layout parameters are documented in page size, margins and orientation.

Headers, footers and page numbers

Running headers and footers go in header and footer as small HTML fragments, drawn on every page. Elements inside them with the classes date, title, url, pageNumber and totalPages are filled in by the renderer. Set a top margin for a header and a bottom margin for a footer, or the request is refused for lack of space.

1report = client.generate_pdf(2    GeneratePDFParams(3        url="https://en.wikipedia.org/wiki/Portable_Document_Format",4        page_size_type=PageSizeType.A4,5        header="""6        <div style="width: 100%; font-size: 10px; padding: 0 15mm; color: #52606d;">7          Portable Document Format, exported <span class="date"></span>8        </div>9        """,10        footer="""11        <div style="width: 100%; font-size: 10px; padding: 0 15mm; text-align: right; color: #52606d;">12          Page <span class="pageNumber"></span> of <span class="totalPages"></span>13        </div>14        """,15        margin=PdfPageMargin(top="25mm", bottom="20mm"),16    )17)1819with open("header-footer.pdf", "wb") as f:20    f.write(client.get_file(GetFileParams(document_id=report["id"])))2122print(f"Header/footer -> {report['id']}")
  • Always give the fragment a font-size. The default for header templates is minute.
  • The fragment is its own document, so the page's CSS is not applied to it. Inline every style you need.
  • Match the fragment's horizontal padding to the page margins, since it is laid out across the whole paper width.

Want a logo, a background colour or a custom font in the header? Each has a catch, explained in headers and footers.

Jinja templates with your own data

An invoice, a contract or a report starts as data in your code. Jinja is the natural fit in Python, and it is the same engine Flask uses for web pages, so the template that renders your invoice page can render the PDF too.

1from jinja2 import Template23order = {4    "number": "2026-0142",5    "customer": "Acme GmbH",6    "lines": [7        {"description": "API plan, Business", "quantity": 1, "unit_price": 79.00},8        {"description": "Extra documents", "quantity": 2000, "unit_price": 0.01},9    ],10}1112template = Template("""13<h1>Invoice #{{ order.number }}</h1>14<p>Billed to {{ order.customer }}</p>15<table>16  <tr><th>Item</th><th>Qty</th><th>Amount</th></tr>17  {% for line in order.lines %}18  <tr>19    <td>{{ line.description }}</td>20    <td>{{ "{:,}".format(line.quantity) }}</td>21    <td>EUR {{ "{:,.2f}".format(line.quantity * line.unit_price) }}</td>22  </tr>23  {% endfor %}24</table>25<p>Total EUR {{ "{:,.2f}".format(total) }}</p>26""", autoescape=True)2728total = sum(line["quantity"] * line["unit_price"] for line in order["lines"])2930templated = client.generate_pdf(31    GeneratePDFParams(html=template.render(order=order, total=total), page_size_type=PageSizeType.A4)32)3334with open("template.pdf", "wb") as f:35    f.write(client.get_file(GetFileParams(document_id=templated["id"])))3637print(f"Template -> {templated['id']}")
  • autoescape=True matters. Without it a customer name containing <script> is rendered as markup. Flask's render_template turns it on for .html files by default.
  • Compute totals in Python and pass them in, as the sample does, rather than in the template. It keeps the arithmetic testable.
  • In Django, render_to_string produces the same kind of string from a Django template. Send it as html.

Pages that render with JavaScript

Charts and data loaded after the page's load event do not make it into the PDF unless you tell the renderer to wait. The reliable approach is a marker element that the page inserts when it has finished, matched by wait_for_selector. Here a setTimeout plays the part of a slow request.

1chart_html = """2<h1>Sales report</h1>3<div id="chart">Loading…</div>4<script>5  // Stands in for a chart library or a fetch() that finishes later6  setTimeout(() => {7    document.getElementById("chart").textContent = "Q4 revenue: EUR 1.2M";8    document.body.insertAdjacentHTML("beforeend", '<div id="chart-ready"></div>');9  }, 800);10</script>11"""1213chart = client.generate_pdf(14    GeneratePDFParams(html=chart_html, wait_for_selector="#chart-ready")15)1617with open("wait.pdf", "wb") as f:18    f.write(client.get_file(GetFileParams(document_id=chart["id"])))1920print(f"Wait -> {chart['id']}")
  • When the selector does not appear within 30 seconds the API answers 422 instead of returning a partial PDF.
  • wait_for_network_idle is for pages you cannot modify, and delay is a fixed wait in milliseconds to use only when nothing else applies.
  • Need the page in a particular state first, animations off or a panel expanded? Pass the script as javascript.

The comparison of these options lives in waiting for dynamic content.

Error handling

A failed request raises pdfgate.errors.PDFGateError, whose message includes the HTTP status and the API's explanation. Missing required parameters raise ParamsValidationError, a subclass, before anything is sent. The listing forces a failure with a loopback URL.

1from pdfgate.errors import PDFGateError23try:4    client.generate_pdf(GeneratePDFParams(url="http://localhost:3000/report"))5except PDFGateError as e:6    print(f"API error: {e}")

The statuses to plan for:

  • 400, a parameter the API refused, named in the message. The local URL is one case, a badly formed page_ranges another.
  • 401, no key or a wrong one.
  • 422, the page was reachable but the render failed, which nine times out of ten is a wait_for_selector that hit its 30 second limit.
  • 429, more simultaneous renders than the plan allows. Pause briefly, then retry.

Network errors are wrapped in the same PDFGateError with a message beginning Request failed, so a single except clause handles both kinds.

In Flask, Django or FastAPI

Instantiate the client once at import time and call it from your views. The first route sends the bytes with send_file. The second asks for a temporary link via pre_signed_url_expires_in and returns JSON, so large files never pass through the web process.

1import io2import os3from datetime import date45from flask import Flask, jsonify, send_file6from pdfgate import GeneratePDFParams, GetFileParams, PageSizeType, PDFGate78app = Flask(__name__)9client = PDFGate(os.environ["PDFGATE_API_KEY"])101112# Returns the PDF directly13@app.get("/invoices/<number>/pdf")14def invoice_pdf(number):15    html = f"<h1>Invoice #{number}</h1><p>Generated on {date.today()}</p>"1617    document = client.generate_pdf(18        GeneratePDFParams(html=html, page_size_type=PageSizeType.A4)19    )20    pdf = client.get_file(GetFileParams(document_id=document["id"]))2122    return send_file(23        io.BytesIO(pdf),24        mimetype="application/pdf",25        download_name=f"invoice-{number}.pdf",26    )272829# Returns a temporary download link instead of the bytes30@app.post("/invoices/<number>/pdf-link")31def invoice_pdf_link(number):32    document = client.generate_pdf(33        GeneratePDFParams(html=f"<h1>Invoice #{number}</h1>", pre_signed_url_expires_in=3600)34    )35    return jsonify(36        id=document["id"],37        fileUrl=document["file_url"],38        expiresAt=document["expires_at"],39    )
  • Django needs the same three calls in a view that returns HttpResponse(pdf, content_type="application/pdf"). FastAPI can await generate_pdf_async and get_file_async and return a Response with the PDF media type.
  • Pre-signed links are only issued when file storage is enabled for the account. Otherwise the API returns 422. The lifetime is between 60 seconds and 24 hours.
  • Each render blocks for a few seconds. For batch work, gather the async variants with asyncio.gather, keeping the concurrency under the plan's limit.

Without the SDK (requests)

Everything above is one endpoint, POST /generate/pdf, taking JSON and returning the PDF. With requests that is a single call, no package required.

1import os23import requests45response = requests.post(6    "https://api.pdfgate.com/generate/pdf",7    headers={"Authorization": f"Bearer {os.environ['PDFGATE_API_KEY']}"},8    json={9        "html": "<h1>Hello from requests</h1>",10        "pageSizeType": "a4",11        "margin": {"top": "20mm", "bottom": "20mm"},12    },13)1415if response.status_code != 201:16    print(response.json())17    raise SystemExit(1)1819with open("http.pdf", "wb") as f:20    f.write(response.content)2122print("requests -> http.pdf")
  • Success is 201. Include jsonResponse: true to receive the document record rather than the file.
  • For a test_ key the host must be api-sandbox.pdfgate.com. The SDK routes by prefix, a raw call does not.
  • Passing the payload with json= handles serialisation and the content type, so HTML containing quotes needs no manual escaping.

Where to go next

Frequently asked questions

Which Python versions does the SDK support?

Python 3.9 and later. The only dependency is httpx, so there are no system libraries to install, which is the usual trouble with WeasyPrint.

How do I add page numbers to a PDF generated from Python?

Pass header and footer HTML with the pageNumber and totalPages CSS classes, and set the top and bottom margins so there is room to draw them. The renderer fills the classes in on every page.

Does the SDK work with async frameworks like FastAPI?

Yes. Every method has an async twin with an _async suffix, for example generate_pdf_async and get_file_async, so you can await them directly in FastAPI instead of running the sync calls in a thread.

What happens when a request fails?

The SDK raises pdfgate.errors.PDFGateError with the HTTP status and the API's message in the exception text. A 400 means an invalid parameter, 401 a bad key, 422 a page that could not be rendered, and 429 more concurrent conversions than the plan allows.

Everything this page did not cover is described on the HTML to PDF API page.

This website uses cookies to offer a better browsing experience. By clicking on the "Accept" button you accept our Cookie policy.