Converting Markdown to PDF in Python: the five real options
There is no single pip install markdown-to-pdf that does the whole job well, because the job has two halves: parsing Markdown into HTML, which Python does fine, and laying HTML out onto pages, which Python does not do natively. Every library below solves the second half by wrapping something else: a CSS layout engine, a TeX distribution or a browser. The choice therefore comes down to what you are willing to install and how much control over the output you need. All snippets assume Python 3.10 or newer and a file called report.md, like the one produced by the script in the converter above.
1. markdown + WeasyPrint: the pure-Python route
Python-Markdown turns the text into HTML and WeasyPrint lays the HTML out with a CSS engine written in Python. This is the most common combination and the one to reach for first.
import markdown
from weasyprint import HTML
html = markdown.markdown(
open("report.md", encoding="utf-8").read(),
extensions=["tables", "fenced_code", "codehilite", "toc"],
)
css = "@page { size: A4; margin: 2cm } body { font: 11pt Georgia }"
HTML(string=f"<style>{css}</style>{html}").write_pdf("report.pdf")
Pitfalls. WeasyPrint needs Pango, Cairo and GDK-PixBuf as system libraries; on macOS that is brew install pango, on Debian apt install libpango-1.0-0 libpangoft2-1.0-0, and on Windows the GTK runtime, which is the step that fails most often. The codehilite extension only colours code if Pygments is installed and you include a Pygments CSS file; otherwise you get plain black text. Python-Markdown's tables extension is not GitHub-flavoured: task lists, strikethrough and autolinks need extra extensions or a switch to markdown-it-py. Remote images are fetched synchronously and time out slowly.
2. md2pdf and markdown-pdf: thin wrappers
Packages such as md2pdf bundle the two steps above into one call: md2pdf("report.pdf", md_file_path="report.md", css_file_path="style.css"). They save ten lines of code but inherit every WeasyPrint dependency and usually pin an older Python-Markdown, so the same table and code-highlighting gaps appear. Use them for a quick script; for anything you maintain, call the two libraries directly so you can upgrade them independently.
3. pypandoc: Pandoc plus a LaTeX engine
Pandoc is the most complete Markdown parser available, and pypandoc calls it from Python. For PDF output Pandoc hands the document to a LaTeX engine, which produces the best-looking typography of any option here, especially for math.
import pypandoc
pypandoc.convert_file(
"report.md", "pdf", outputfile="report.pdf",
extra_args=["--pdf-engine=xelatex", "-V", "geometry:margin=2cm",
"-V", "mainfont=Georgia"],
)
Pitfalls. You need Pandoc and a TeX distribution installed; TeX Live is 2 to 4 GB and MiKTeX installs packages on first use, which breaks in an offline CI container. The default pdflatex engine cannot handle most Unicode, so an em dash or a Chinese character stops the build with a cryptic error; always pass --pdf-engine=xelatex or lualatex. Wide tables overflow the page unless you convert them to LaTeX longtable with a filter. Raw HTML inside the Markdown, common in files exported from Notion or ChatGPT, is silently dropped. Build times of five to fifteen seconds per document are normal.
4. pdfkit: wkhtmltopdf, which you should not start new projects on
pdfkit wraps the wkhtmltopdf binary, a 2012-era WebKit build. It renders HTML fast and used to be the default answer, but the project was archived in 2023 and its WebKit has no CSS grid, no flexbox gap and patchy break-inside support. It still works for simple documents, and many tutorials still recommend it, but every year it renders less of the CSS you write. If you already have it in production, keep it; if you are choosing today, pick option 1 or 5.
5. Playwright: a real browser, when fidelity matters most
Headless Chromium renders exactly what Chrome shows, which makes it the right choice when the Markdown contains Mermaid diagrams, KaTeX or anything that needs JavaScript to draw.
import markdown
from playwright.sync_api import sync_playwright
html = markdown.markdown(open("report.md", encoding="utf-8").read(),
extensions=["tables", "fenced_code"])
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.set_content(html, wait_until="networkidle")
page.pdf(path="report.pdf", format="A4",
margin={"top": "2cm", "bottom": "2cm", "left": "2cm", "right": "2cm"},
print_background=True)
browser.close()
Pitfalls. playwright install chromium downloads about 300 MB and needs a dozen shared libraries on a bare Linux image, so container builds get slow and fragile. Each conversion starts a browser process, roughly one second of overhead, and memory use climbs quickly under concurrency. Running headers and footers use Chromium's own template syntax rather than @page margin boxes. And you now operate a browser in production, with all the sandboxing and update work that implies.
What about ReportLab, FPDF and PyMuPDF?
These libraries write PDF pages directly and are excellent for invoices and forms, but none of them understands Markdown or HTML. Using them means writing your own layout code for headings, lists, tables and code blocks, which is weeks of work to reach what a CSS engine gives you for free. They are the wrong tool for this job unless you need pixel-exact placement of a few fields.
The sixth option: call the MDtoDOCX API from Python
If what you want is "POST Markdown, get a PDF", none of the above is worth maintaining. The MDtoDOCX API renders with the same engine as this page, so tables, code highlighting, images, equations and Mermaid diagrams behave exactly as they do in the preview, and there is nothing to install beyond requests.
import requests
resp = requests.post(
"https://api.mdtodocx.work/v1/convert",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={
"from": "markdown",
"to": "pdf",
"content": open("report.md", encoding="utf-8").read(),
"options": {"paper": "A4", "font": "Georgia", "page_numbers": True},
},
timeout=60,
)
resp.raise_for_status()
open("report.pdf", "wb").write(resp.content)
Uploaded content is processed in memory and deleted within 24 hours; see the privacy policy for the details. API plans start at $19 for 5,000 conversions and are listed on Pricing; the API is currently in private beta, so email [email protected] for a key. For one file today, the converter at the top of this page is free and runs entirely in your browser.