Rendering Markdown to HTML in Python
7 July 2026 · 5 min read
markdown.markdown(text) is the first line almost everyone writes, and it works. Then a table goes into the README, the HTML comes back with pipes sitting in a paragraph, and the search starts. Python has four Markdown libraries worth knowing about. The differences between them are mostly about what is switched on by default and how far you can change the output.
The markdown package, and the extensions it leaves off
Python-Markdown — pip install markdown, imported as markdown — is the oldest of the four. On its own it implements the original Markdown syntax and nothing more. No tables. No fenced code blocks. No footnotes. Those are extensions, and they stay off until you name them.
import markdown
html = markdown.markdown(
text,
extensions=["tables", "fenced_code", "toc", "sane_lists"],
extension_configs={"toc": {"anchorlink": True}},
)
The flags people miss are almost always these:
| What you expected | Extension | Note |
|---|---|---|
| GitHub-style tables | tables |
|
| Triple-backtick code fences | fenced_code |
|
| Ids and anchors on headings | toc |
also fills md.toc |
| Highlighted code | codehilite |
needs Pygments installed |
| Footnotes | footnotes |
|
{: .note} attributes on elements |
attr_list |
|
| A single newline becoming a line break | nl2br |
extra switches on a bundle — abbr, attr_list, def_list, fenced_code, footnotes, md_in_html, tables — and gets you close to what you probably assumed you had. Note what is still missing from it: nl2br and codehilite.
For more than one document, build the converter once with markdown.Markdown(extensions=[...]) and call .reset() between files. Footnotes and the table of contents carry state, so without the reset the second page inherits the first page's footnotes.
markdown2 and its extras
markdown2 is a single module with the same shape and a different vocabulary: features are extras, and they are also off by default.
import markdown2
html = markdown2.markdown(
text,
extras=["tables", "fenced-code-blocks", "strike", "header-ids", "footnotes"],
)
The trade-off is fewer moving parts against a smaller ecosystem: if you need something neither library ships, Python-Markdown has a documented extension API and third-party extensions to draw on.
Mind the spelling. The two libraries name the same feature differently — fenced_code against fenced-code-blocks — so keep the list in one constant rather than retyping it at each call site. Mixing the two up is the usual reason one page renders a table and another prints pipes.
mistune, when you want to change the output
mistune is a pure-Python Markdown parser built around plugins and renderers. mistune.html(text) is the convenience call; create_markdown is where the decisions live.
import mistune
render = mistune.create_markdown(
escape=True,
plugins=["table", "strikethrough", "task_lists", "url"],
)
html = render(text)
escape=True escapes raw HTML in the source instead of passing it through, which is what you want when the Markdown came from someone else. Pass escape=False when the source is yours and it contains deliberate HTML.
The real reason to reach for mistune is the renderer. Subclass HTMLRenderer, override the method for one node type, and images or links come out the shape you want — carrying loading="lazy", say — with no regular expression run over the finished string.
markdown-it-py for CommonMark
When the requirement is "matches the spec", markdown-it-py is the direct answer. It is a Python port of the JavaScript markdown-it and tracks CommonMark closely. Presets choose a starting point and rules are enabled by name.
from markdown_it import MarkdownIt
md = MarkdownIt("commonmark")
md.enable(["table", "strikethrough"])
html = md.render(text)
Because it is the same parser family as the JS original, a Python backend and a Node front end can be made to agree on the output. Which flavour you are targeting matters more than which library you pick; CommonMark, GFM and the flavours sets out the differences, and the same libraries have counterparts covered in rendering Markdown in JavaScript.
Sanitising the HTML
None of these four is a sanitiser. Markdown permits raw HTML by design, so a <script> tag in the source is a <script> tag in the output unless something escapes or removes it. Python-Markdown says as much in its own documentation; its old safe_mode was removed rather than repaired.
bleach was the standard answer for years. It is now deprecated and unmaintained; the usual replacement is nh3, a Python binding to the Rust ammonia library.
import nh3
safe = nh3.clean(
html,
tags={"p", "a", "code", "pre", "h1", "h2", "h3", "ul", "ol", "li", "table",
"thead", "tbody", "tr", "th", "td", "em", "strong", "blockquote"},
attributes={"a": {"href", "title"}, "code": {"class"}},
)
Clean the HTML, not the Markdown. Filtering the source text is guesswork, because the parser is what decides which characters become a tag. Sanitising Markdown safely works through the failure cases. transformpipe is built the same way: marked renders, then DOMPurify in the browser and the xss package on the server clean the result against one shared allow-list, so both sides produce the same document. Its heading ids carry a doc- prefix, which keeps them out of DOM-clobbering territory.
A script that converts a directory
Put the pieces together and a whole folder is about fifteen lines.
from pathlib import Path
import markdown, nh3
TEMPLATE = "<!doctype html><meta charset=utf-8><title>{title}</title>{body}"
md = markdown.Markdown(extensions=["tables", "fenced_code", "toc"])
src, out = Path("docs"), Path("build")
for path in sorted(src.rglob("*.md")):
body = nh3.clean(md.convert(path.read_text(encoding="utf-8")))
md.reset()
target = out / path.relative_to(src).with_suffix(".html")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(TEMPLATE.format(title=path.stem, body=body), encoding="utf-8")
print(f"{path} -> {target}")
Three details do the work. encoding="utf-8" on both the read and the write, because the platform default is not UTF-8 everywhere and an em dash is enough to break the job. sorted(), so the build order is the same on every machine. md.reset() inside the loop, for the reason above.
One trap the script above walks into: nh3.clean with no arguments uses ammonia's default allow-list, and id is not on it, so the heading anchors toc just added are stripped straight back out. Allow the attribute per tag — attributes={"h1": {"id"}, "h2": {"id"}} — and prefix the values yourself, because a bare id on a heading can shadow a DOM property of the same name.
Choose by requirement: Python-Markdown for extensions and plugins, markdown2 when one call with a list of extras is the whole job, mistune when the HTML has to come out a particular shape, markdown-it-py when it must match the spec. Then write the script and pin the library in your requirements file. If all you need is a page a colleague can open, skip the build — drop the file into transformpipe, or have the script POST it to /api/v1/documents?share=link, which stores the document and returns the read-only link in one call; the endpoints are in /docs.