Pandas DataFrame to PDF

Four ways to get a pandas DataFrame into a PDF, run on the same real dataset (seaborn's mpg.csv: 398 rows, 9 columns, a long name column and 6 missing values). The two most common ones lose data without an error. Tested with pandas 3.1, matplotlib 3.11, WeasyPrint 67 and Chromium (Playwright 1.64 and our API).

Quick answer

ApproachResult on 398 × 9
df.to_html() + WeasyPrint, no CSS19 pages, the origin column cut in half and name missing on every page.
matplotlib ax.table()1 page at 2 pt text; only rows 164-233 are on the page, the header and 328 rows are off it.
to_html() + print CSS + WeasyPrint9 pages, every column, header repeated on each page, "Page X of 9".
No browser needed. Our pick for plain tables.
Styler (colors, bars) + Chromium8 pages with the colors kept. Styler.bar() made it 9 s and 6.5 MB until we rewrote the bars (below): 0.9 s, 202 KB.

1. The naive way: to_html() straight into WeasyPrint

import pandas as pd
from weasyprint import HTML

df = pd.read_csv("https://raw.githubusercontent.com/mwaskom/seaborn-data/master/mpg.csv")
df.to_html("report.html")
HTML("report.html").write_pdf("report.pdf")

No error, a 19-page A4 PDF. But pandas writes a bare table with no width limit, so it runs off the right edge of the page: origin is cut mid-word and name (the car models) isn't on any page. pdftotext report.pdf - | grep -c chevrolet returns 0 for a dataset full of Chevrolets. The border="1" pandas adds doesn't draw grid lines in WeasyPrint either, and the index column takes space.

2. The matplotlib table

A common answer is to draw the frame as a matplotlib table and save the figure as a PDF:

import pandas as pd
import matplotlib.pyplot as plt

df = pd.read_csv("https://raw.githubusercontent.com/mwaskom/seaborn-data/master/mpg.csv")
fig, ax = plt.subplots(figsize=(8.27, 11.69))  # A4 in inches
ax.axis("off")
ax.table(cellText=df.values, colLabels=df.columns, loc="center")
fig.savefig("report-mpl.pdf")

matplotlib shrinks the text to fit, down to 2 pt here, and then still doesn't fit: the table is taller than the page and centred on it, so the PDF shows rows 164 to 233 only. The header and the other 328 rows are outside the page box. A figure is one page, with no page breaks; this only works for a few dozen rows, or if you split the frame into chunks yourself and write one page each with PdfPages.

3. to_html() with print CSS (WeasyPrint)

The fix is a few lines of CSS: a full-width table with smaller text, no index, and a page footer. Long tables then break across pages by themselves.

import pandas as pd
from weasyprint import HTML

df = pd.read_csv("https://raw.githubusercontent.com/mwaskom/seaborn-data/master/mpg.csv")

table = df.to_html(index=False, border=0, classes="data", na_rep="",
                   float_format=lambda x: f"{x:,.1f}")

html = f"""<!doctype html>
<html><head><meta charset="utf-8"><style>
  @page {{ size: A4; margin: 1.5cm;
          @bottom-right {{ content: "Page " counter(page) " of " counter(pages); font-size: 8pt; }} }}
  body {{ font-family: sans-serif; font-size: 8pt; }}
  table.data {{ width: 100%; border-collapse: collapse; }}
  .data th {{ background: #f0f0f0; text-align: left; }}
  .data th, .data td {{ padding: 3px 5px; border-bottom: 1px solid #ddd; }}
  .data tr {{ break-inside: avoid; }}
  .data tbody tr:nth-child(even) {{ background: #fafafa; }}
</style></head><body>
<h1>Fuel economy, {len(df)} cars</h1>
{table}
</body></html>"""

HTML(string=html).write_pdf("report.pdf")

Result: 9 A4 pages, all 9 columns, long model names wrap inside their cell, and every page ends with "Page N of 9". The <thead> that pandas writes is repeated at the top of each page with no extra CSS (WeasyPrint and Chromium both do this). break-inside: avoid keeps a row from being split between two pages. What each argument does:

Too many columns for portrait? Use size: A4 landscape in the @page rule, or lower the font size.

4. Styled frames (colors, bars) in Chromium

df.style adds conditional formatting: color scales, data bars, highlighted cells. WeasyPrint does render it, but the same frame came out at 14 pages (the 10em bar column squeezes the names) and took 5.1 s. Chrome's print engine gave 8 pages. If you run Chrome anyway (Playwright, Puppeteer), or don't want a browser on your server, an HTML to PDF API does it in one request:

import os
import re
import pandas as pd
import requests

df = pd.read_csv("https://raw.githubusercontent.com/mwaskom/seaborn-data/master/mpg.csv")

styled = (df.style
          .hide(axis="index")
          .format(precision=1, na_rep="")
          .background_gradient(subset=["mpg"], cmap="RdYlGn")
          .bar(subset=["horsepower"], color="#9ecae1"))

html = f"""<!doctype html>
<html><head><meta charset="utf-8"><style>
  body {{ font-family: sans-serif; font-size: 8pt; }}
  table {{ width: 100%; border-collapse: collapse; }}
  th {{ background: #f0f0f0; text-align: left; }}
  th, td {{ padding: 3px 5px; border-bottom: 1px solid #ddd; }}
  tr {{ break-inside: avoid; }}
</style></head><body>
<h1>Fuel economy, {len(df)} cars</h1>
{styled.to_html()}
</body></html>"""

# Styler.bar() draws each bar as a hard-stop linear-gradient, which Chromium
# prints very slowly (9 s and 6.5 MB here). The same bar as a sized solid fill
# looks identical and prints in under a second:
html = re.sub(r"linear-gradient\(90deg, (#\w+) ([\d.]+)%, transparent \2%\)",
              r"linear-gradient(\1, \1) no-repeat 0 0 / \2% 100%", html)

r = requests.post("https://shotmint.creasework.com/v1/pdf",
                  headers={"X-API-Key": os.environ["SHOTMINT_KEY"]},
                  json={"html": html, "format": "A4", "margin": "1.5cm"},
                  timeout=60)
r.raise_for_status()
open("report-api.pdf", "wb").write(r.content)

Two things we measured on this frame, both in Playwright's Chromium and in our API, since they run the same engine:

The request is a plain HTTP POST, so it works from a notebook, a cron job, Airflow or Lambda without installing Chrome. For a landscape page, add "landscape": true to the JSON.

Which one?

For plain tables, use section 3: to_html(), a dozen lines of CSS and WeasyPrint, no browser. Use Chromium (yours or an API) when the frame is styled with df.style, sits next to JavaScript charts, or must look exactly like it does in the browser. Avoid the matplotlib table for anything longer than a page. Whatever you pick, check the PDF itself (pdftotext out.pdf - | grep a value from the last column), since none of these failures raise an error.

Related: HTML to PDF in Python, PDFs with charts, CSS page breaks, page numbers and footers, missing background colors.

What it costs

One PDF = one credit. Free keys get 25 renders a month, no signup. €5 buys 2,000 renders, credits never expire and there's no subscription.

Try it with 25 free renders a month

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). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons