Convert HTML to PDF in Python with WeasyPrint
Table of contents
Nutrient DWS handles rendering, fonts, and CSS for you — start with 50 free credits.
There are two practical ways to convert HTML to PDF in Python: WeasyPrint(opens in a new tab) (open source, Python 3.10+, runs locally) and the Nutrient PDF generator API (cloud, with no local rendering engine to maintain). This guide covers examples for both — including page breaks, headers, footers, Django, and Jinja2 templates — and when each one is the right pick.
For invoice downloads, analytics exports, or HR documents, choose a renderer based on your layout requirements and on whether you want to manage it locally.
Why convert HTML to PDF?
HTML works on the web, but some outputs — invoices, analytics exports, archived records — need a fixed-layout format. Converting HTML to PDF preserves your content’s structure and styling in a format that prints and shares consistently across devices.
Method 1: Using WeasyPrint
WeasyPrint(opens in a new tab) is an open source Python library for converting HTML and CSS to PDF. The installation documentation(opens in a new tab) lists Python 3.10 or higher and Pango 1.44 or higher as requirements. It supports modern CSS features, including flexbox, grid, and CSS paged media — making it a solid choice for reports, invoices, and printable documents generated from web content.
Installation
Install the native dependencies for your operating system using the WeasyPrint installation instructions(opens in a new tab) before running pip. Python wheels don’t remove the need for libraries such as Pango. The commands below use a Unix-style shell and a virtual environment.
Set up a virtual environment:
Terminal window python3 -m venv venvsource venv/bin/activateInstall WeasyPrint:
Terminal window python3 -m pip install weasyprint
You can also install with Poetry or conda:
poetry add weasyprint # Poetryconda install -c conda-forge weasyprint # CondaFor more detailed installation instructions, refer to the WeasyPrint documentation(opens in a new tab).
Quick convert from the command line
WeasyPrint installs a CLI tool. If you don’t need Python code, you can convert HTML to PDF directly from your terminal:
weasyprint input.html output.pdfThis also works with URLs:
weasyprint https://example.com page.pdfBasic example: Generating a PDF from HTML
The following example converts an HTML string to a PDF file:
from weasyprint import HTML
html_content = """<!DOCTYPE html><html><head> <title>Sample Report</title> <style> body { font-family: Arial, sans-serif; } h1 { color: #333; } </style></head><body> <h1>Monthly Sales Report</h1> <p>This is a sample PDF report generated from HTML using WeasyPrint.</p></body></html>"""
HTML(string=html_content).write_pdf("report.pdf") 
Page layout, headers, and footers with @page
For multipage documents, CSS @page rules control margins, page size, and running headers/footers. WeasyPrint supports the CSS Paged Media specification, which lets you define printed page layout directly in your stylesheet:
from weasyprint import HTML, CSS
html_content = """<!DOCTYPE html><html><head> <title>Sales Report</title></head><body> <h1>Q1 Sales Report</h1> <p>Page content goes here...</p></body></html>"""
css = CSS(string=""" @page { size: A4; margin: 2cm;
@top-center { content: "Q1 Sales Report"; font-size: 10pt; color: #666; }
@bottom-right { content: "Page " counter(page) " of " counter(pages); font-size: 9pt; } }""")
HTML(string=html_content).write_pdf("report.pdf", stylesheets=[css])Running headers with images
Text-based @page headers work for simple cases, but if you need a logo or HTML element on every page, use CSS position: running(). This pulls an element out of the document flow and places it into a page margin box:
from weasyprint import HTML
html_content = """<!DOCTYPE html><html><head> <style> .header { position: running(pageHeader); }
@page { margin: 2.5cm 2cm 2cm 2cm;
@top-left { content: element(pageHeader); }
@bottom-center { content: "Page " counter(page); font-size: 9pt; } } </style></head><body> <div class="header"> <img src="logo.png" style="height: 30px;" /> <span>Acme Corp</span> </div> <h1>Annual Report</h1> <p>Report content...</p></body></html>"""
# base_url tells WeasyPrint where to resolve relative asset paths# (such as logo.png). Without it, the image would be skipped.HTML(string=html_content, base_url=".").write_pdf("report.pdf")The running() function removes the .header element from the normal flow, and element(pageHeader) renders it in the @top-left margin box on every page. The base_url argument is required whenever your HTML references relative assets — pass the directory that contains the assets (or an absolute file path or URL) so WeasyPrint can fetch them.
Page breaks
Use the break-before CSS property to force a new page at section boundaries:
html_content = """<!DOCTYPE html><html><head> <style> .section { break-before: page; } </style></head><body> <h1>Section 1</h1> <p>First section content.</p>
<div class="section"> <h1>Section 2</h1> <p>This starts on a new page.</p> </div></body></html>"""
HTML(string=html_content).write_pdf("report.pdf")Tables that span multiple pages
For long data tables, use thead with display: table-header-group to repeat column headers on every page:
html_content = """<!DOCTYPE html><html><head> <style> thead { display: table-header-group; } tfoot { display: table-footer-group; } tr { break-inside: avoid; } </style></head><body> <table> <thead> <tr><th>Date</th><th>Description</th><th>Amount</th></tr> </thead> <tbody> <tr><td>2026-01-15</td><td>Invoice #1042</td><td>$1,200</td></tr> <tr><td>2026-01-22</td><td>Invoice #1043</td><td>$850</td></tr> <!-- More rows... --> </tbody> </table></body></html>"""
HTML(string=html_content).write_pdf("ledger.pdf")The break-inside: avoid on tr prevents rows from splitting across pages.
Using WeasyPrint in Django
To generate a PDF response directly from a Django view:
from django.http import HttpResponsefrom weasyprint import HTML
def generate_report(request): html_content = """ <!DOCTYPE html> <html> <body> <h1>Report</h1> <p>Generated from a Django view.</p> </body> </html> """
pdf = HTML(string=html_content).write_pdf()
response = HttpResponse(pdf, content_type="application/pdf") response["Content-Disposition"] = 'attachment; filename="report.pdf"' return responseThe example above uses a static HTML string. To inject dynamic data, render a Django template to a string first with render_to_string. Then pass the result to HTML(string=...):
from django.http import HttpResponsefrom django.template.loader import render_to_stringfrom django.utils import timezonefrom weasyprint import HTML
def generate_report(request): context = {"timestamp": timezone.now()} html_content = render_to_string("reports/report.html", context)
pdf = HTML(string=html_content).write_pdf()
response = HttpResponse(pdf, content_type="application/pdf") response["Content-Disposition"] = 'attachment; filename="report.pdf"' return responseUsing WeasyPrint with Jinja2 templates
Outside Django, Jinja2 is the standard Python templating engine. Keep your HTML template in a separate file (e.g. templates/invoice.html) and load it through Jinja2’s FileSystemLoader — the example explicitly enables autoescaping with select_autoescape:
from jinja2 import Environment, FileSystemLoader, select_autoescapefrom weasyprint import HTML
env = Environment( loader=FileSystemLoader("templates/"), autoescape=select_autoescape(["html"]),)template = env.get_template("invoice.html")
items = [ {"name": "Consulting", "amount": "1,200"}, {"name": "Support", "amount": "850"},]
html_content = template.render( invoice_number="1042", customer_name="Acme Corp", items=items, total="2,050",)
HTML(string=html_content).write_pdf("invoice.pdf")Your templates/invoice.html uses standard Jinja2 syntax — double curly braces for values and Jinja’s for ... endfor block tags to loop over the list. With autoescape enabled, any user-supplied values are HTML-escaped automatically.
Install Jinja2 with pip install Jinja2. For Flask projects, Flask-WeasyPrint(opens in a new tab) wraps both libraries with route-aware URL handling.
WeasyPrint limitations
WeasyPrint works well for reports and structured documents, but it has known constraints:
- No JavaScript execution — Dynamic content rendered by JavaScript won’t appear in the PDF, since HTML must be fully rendered before conversion.
- CSS support — WeasyPrint is a paged-media renderer, not a browser. Check its supported features(opens in a new tab) and test your layouts.
- Performance at scale — Rendering time and memory use depend on the HTML, CSS, tables, images, and available resources. Benchmark representative documents and consult the performance guidance(opens in a new tab) before choosing concurrency limits.
- System dependencies — Follow the operating-system-specific instructions for Pango and its dependencies, including in containers and serverless deployments.
Method 2: Generating PDFs at scale with Nutrient API
A hosted API may be useful when:
- You’re handling dozens of templates across the same product.
- You need consistent branding across thousands of documents.
- You’re injecting dynamic data into structured layouts.
- You need multipage reports with headers, footers, and embedded fonts that render reliably.
The Nutrient PDF generator API handles these cases. Instead of combining libraries and working around layout issues, you send structured instructions and assets to a single API endpoint and get styled PDFs back.
What it solves
You send HTML, CSS, fonts, and images to the API. It sends back a PDF. Here’s what the API handles for you:
- It renders multipage layouts with headers, footers, and page breaks.
- It takes fonts and images alongside the HTML and CSS.
- It works with reusable HTML and CSS templates for receipts, reports, and onboarding documents.
- It accepts rendering jobs over HTTP.
- It needs no local rendering engine, so you test templates and assets against the hosted renderer.
The free plan includes 50 credits per month. Different operations consume different amounts of credits, so the number of PDFs you can generate varies. To get your API key, sign up for a free account(opens in a new tab).
Requirements
To get started, you’ll need a Nutrient API key, Python(opens in a new tab), pip(opens in a new tab), and the Requests library(opens in a new tab).
To access your Nutrient API key, sign up for a free account(opens in a new tab). Once you’ve signed up, you can find your API key in the Dashboard > API keys section(opens in a new tab).
Install the Requests(opens in a new tab) HTTP library:
python -m pip install requestsHow it works
Use your preferred HTTP client to send your index.html, CSS, fonts, and images to our /build endpoint, along with rendering instructions.
In Python, that looks like this:
import requestsimport json
response = requests.request( 'POST', 'https://api.nutrient.io/build', headers = { 'Authorization': 'Bearer your_api_key_here' # Replace with your actual API key. }, files = { 'index.html': open('index.html', 'rb') }, data = { 'instructions': json.dumps({ 'parts': [ { 'html': 'index.html' } ] }) }, stream = True)
if response.ok: with open('result.pdf', 'wb') as fd: for chunk in response.iter_content(chunk_size=8096): fd.write(chunk)else: print(response.text) exit()The API renders the document remotely. Check the output with your actual fonts, images, and templates before using the output in production.
Use our templates or bring your own
We provide ready-made templates — multipage HTML and CSS files with headers, page numbers, font embedding, and logos. Use them as-is or customize them to match your branding.
Try it free
The free plan includes 50 credits per month. Usage depends on the operations in each request. Free-plan output includes an evaluation watermark unless you enable pay-as-you-go or choose a paid plan. See current pricing before enabling paid usage.
WeasyPrint vs. Nutrient API: Choosing the right Python PDF generator
| WeasyPrint | Nutrient API | |
|---|---|---|
| Rendering | Local HTML/CSS renderer with paged-media support; test your templates and fonts | Hosted HTML-to-PDF rendering; test your templates, fonts, and asset access |
| Best for | Teams that want local rendering and control of the Python deployment | Teams that want hosted processing and related document operations through HTTP APIs |
| Scale | Benchmark your templates and provision rendering resources | Hosted processing; plan for API limits and credit usage |
| Integration | Python-only, runs locally. Needs environment setup | Language-agnostic HTTP API. Works in serverless, CI, and any environment that can make a POST request |
| Cost | Free, open source (BSD license) | Free tier (50 credits/month), paid plans for higher volume |
Deterministic PDF generation in AI and agent workflows
HTML-to-PDF pipelines increasingly run downstream of a language model: An agent drafts report content, and your service turns it into a document. Keep those two responsibilities separate.
- The model writes content, not documents. Have the model produce structured values, such as a summary string, table rows, and totals. Then validate them against a schema before rendering.
- The template owns the layout. A version-controlled Jinja2 or Django template defines every heading, table, and page rule. Keep the renderer version, fonts, and assets consistent too, and test output when any of them changes.
- Validate before you render. Reject missing fields, malformed dates, and out-of-range numbers at the data boundary — not by proofreading PDFs afterward.
- Keep an audit trail. Store the template version and input payload with each generated document so any PDF can be regenerated or explained later.
Both approaches in this guide fit that pattern. WeasyPrint renders a validated template locally, and the Nutrient PDF generator API does the same over HTTP at volume. For the wider pipeline — extraction, validation, review, and downstream actions — see the AI document automation architecture guide.
Conclusion
WeasyPrint provides local HTML-to-PDF rendering in Python, including paged layouts and template-based reports.
Choose between local and hosted rendering based on layout support, data-handling requirements, operational effort, and measured workload. Nutrient provides a hosted alternative when you prefer to send rendering requests over HTTP.
To try hosted rendering, create a Nutrient account(opens in a new tab) and test representative templates with the PDF generator API.
Are you evaluating other options? Our Python PDF library comparison compares seven tools — including WeasyPrint and Nutrient — for generation, extraction, optical character recognition (OCR), and more.
FAQ
WeasyPrint is an open source Python library that converts HTML and CSS to PDF. It supports modern CSS (flexbox, grid, paged media) and runs locally — no external service needed. Install with pip install weasyprint.
Nutrient API supports PDF generation, annotations, form filling, digital signatures, and OCR. It’s designed for high-volume processing and advanced PDF manipulation.
Yes, WeasyPrint integrates into existing Python projects. Install it via pip and use it in your code to convert HTML to PDF.
Nutrient API offers 50 free credits per month with evaluation-watermarked output. A paid plan or pay-as-you-go enables watermark-free output; check current pricing for usage costs.
Download the provided HTML and CSS templates, edit them in any code editor, and send the modified files to the API. You control the markup, fonts, images, and layout — Nutrient renders it.