HTML to PDF in Python with Playwright: the defaults that bite
A headless browser is a common way to turn HTML into a PDF. Its defaults catch people out: US Letter, no backgrounds, no margins, an invisible footer. Each one measured, and a function that sets them right.
To turn HTML into a PDF from Python today, a common choice is a headless browser: Chromium, driven by Playwright. The page is laid out the way Chrome lays it out, so the CSS you wrote for the screen applies. The older wkhtmltopdf is still in many tutorials. Its GitHub repository is archived, with a last push in November 2022.
Chromium's PDF settings, though, have defaults that catch people out. We rendered the same pages with each default on 25 October 2026, with Playwright 1.58.0 and Chromium 145, and measured the results with pdfinfo, pdftotext and pdftoppm.
Seven defaults, measured#
- The paper is US Letter. Without
format, the page is 612 x 792 points: 8.5 x 11 inches. If you expected A4, every page comes out a different size. - Backgrounds are off. A red box came out white. Table stripes, coloured headers and highlighted totals disappear unless you pass
print_background=True. - There are no margins. The red box ran to the very edge of the sheet. Set margins yourself.
- Your
@pagesize is ignored. With@page { size: A5 }in the CSS andformat="A4", the PDF was A4. Withprefer_css_page_size=Trueit was A5, 420 x 594.96 points. - The default header and footer are not blank. Turn on
display_header_footerwithout templates, and the top of each page shows the date, the time and the page title. The bottom shows the page address and "1/1". For HTML you passed in as a string, that address isabout:blank. - A footer without a font size is invisible. The template does not take the page's font size. Our footer "page 1 of 3" with no size set came out 5 points wide and less than 1 point tall: a speck in the corner. Give the template its own
font-size. - With no bottom margin, the footer prints over your text. The footer and a table row shared the same line.
Two more things are worth knowing. One works in your favour: a table's <thead> is repeated at the top of every page, which we checked on a 120-row invoice over 3 pages. The other is a detail: Chromium's A4 is 595.92 x 842.88 points, which is 210.2 x 297.3 mm, a hair over the 210 x 297 mm of the standard. pdfinfo still calls it A4.
The same HTML, different files#
Render the same HTML twice and you get two files with different bytes. We compared them: they are identical except for two fields, the creation date and the modification date, which Chromium sets to the time of rendering. If you hash PDFs to tell whether something changed, blank those two fields first, or every file will look new.
A function with the defaults set right#
from playwright.sync_api import sync_playwright
FOOTER = ('<div style="font-size: 9px; width: 100%; text-align: center; color: #555;">'
'<span class="pageNumber"></span> / <span class="totalPages"></span></div>')
def html_to_pdf(jobs, paper="A4", margin="15mm"):
"""Render [(html, path), ...] with one browser: real paper size, backgrounds, margins, page numbers."""
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
for html, path in jobs:
page.set_content(html, wait_until="load")
page.pdf(
path=path,
format=paper, # without it you get US Letter
prefer_css_page_size=True, # let @page { size: ... } in the CSS win
print_background=True, # without it, background colours vanish
margin={side: margin for side in ("top", "right", "bottom", "left")},
display_header_footer=True,
header_template="<div></div>", # otherwise Chromium prints the date and the title
footer_template=FOOTER,
)
browser.close()rows = "".join(f"<tr><td>{n}</td><td>Item {n}</td><td>{n * 3}.00</td></tr>" for n in range(1, 121))
invoice = f"""<!doctype html><html><head><title>Invoice 1042</title><style>
body {{ font-family: sans-serif; }} .total {{ background: #ffe08a; }}
</style></head><body><h1>Invoice 1042</h1>
<table><thead><tr><th>No</th><th>Item</th><th>Price</th></tr></thead><tbody>{rows}</tbody></table>
<p class="total">Total due: 21,780.00</p></body></html>"""
t0 = time.time()
html_to_pdf([(invoice, "invoice.pdf")])
print(f"{time.time() - t0:.2f} s with the browser start")The invoice came out as 3 A4 pages, with the table header on each and "1 / 3", "2 / 3" and "3 / 3" at the foot. Over four runs that took 0.6 to 0.75 seconds, most of it starting Playwright and the browser. Twenty invoices through one browser took about 1.2 seconds in all (3.3 on the first, cold run). So start the browser once and render many pages with it, rather than one browser per file.
wait_until="load" waits for the images and stylesheets the HTML refers to. If the page draws part of itself with JavaScript, such as a chart, wait for that element to appear before you call pdf().
Without running a browser yourself#
Chromium needs a few hundred megabytes and its own system libraries. In a small container, a serverless function or a no-code tool, that is often the hard part. Our HTML to PDF actor on Apify runs the same engine for you: headless Chromium, HTML in, PDF out.
- Paper sizes A4, Letter, Legal, A3 and A5, portrait or landscape.
- Margins in millimetres (15 mm unless you say otherwise), backgrounds on by default, and a scale setting.
- A plain-text header and footer where
{page}and{pages}become the page number and the page count. - JavaScript is not run, so the same input lays out the same way every time.
One run takes up to 1,000 documents. Each comes back as a row with a link to its PDF, which you can fetch through the API, from Make, Zapier or n8n, or from an agent over MCP. It is billed per document.
written
5 min readSudoku generator in Python with a unique-solution check
A sudoku with two answers is a misprint. About fifty lines of Python that fill a grid, empty it square by square and keep only puzzles with exactly one solution, and what 200 of them showed about "unique" against "easy".
- 3 min read
Cron does not mean what you think it means
Seven ways a crontab line does something other than what it reads like: step values that restart, two day fields that mean "or", a percent sign that eats your command.
- 2 min read
The key is already public: what to do after you commit a secret
Deleting the commit is not the fix. Revoke the key first, check who used it, then clean up and put a scanner in front of the next push.