Python Markdown: Convert Markdown to HTML in Python

September 11, 2026 · 10 min read

Python Markdown: Convert Markdown to HTML in Python (3 Libraries)

Rendering python markdown takes one line: pip install markdown, then markdown.markdown(text) returns HTML. The catch is that the three popular libraries (Python-Markdown, markdown-it-py, mistune) parse the same file differently. This tutorial runs one sample through all three, shows where the HTML diverges, and ends with a production script that adds extensions and sanitising.

If you only need to convert a file once and don't want to write code, our guide to converting Markdown to HTML covers the no-code routes. This post is for people rendering Markdown from a script, a web app, or a build step.

Is There a Python Library for Markdown?

There are several, and the names cause more confusion than the code. The package you install with pip install markdown is called Markdown on PyPI, but the project is Python-Markdown and the module you import is markdown. As of version 3.10.3 (released 30 July 2026) it requires Python 3.10 or later and ships under the BSD-3-Clause licence.

Two other libraries matter. markdown-it-py (the markdown-it Python port, often written markdown it py) follows the CommonMark spec strictly, matching the JavaScript markdown-it parser it comes from. It powers MyST and Jupyter Book. mistune is a fast parser with a plugin system, described by its author as compatible with sane CommonMark rules. We tested mistune 3.3.4 and markdown-it-py 4.2.0.

One more name to keep straight: markdown2 is a separate, independent implementation by a different maintainer (version 2.5.5 as of March 2026). Installing it doesn't give you Python-Markdown, and its extension names are different.

LibraryInstallImportSpecTables out of the box
Python-Markdown 3.10.3pip install markdownimport markdownGruber's original, not CommonMarkNo (needs tables or extra)
markdown-it-py 4.2.0pip install markdown-it-pyfrom markdown_it import MarkdownItCommonMarkNo (enable table or use gfm-like)
mistune 3.3.4pip install mistuneimport mistuneCommonMark-leaningYes with mistune.html()

How Do I Write Markdown in Python?

The minimal call is the same shape in all three libraries. Each takes a string and returns an HTML string with no wrapping <html> or <body> tags.

import markdown
from markdown_it import MarkdownIt
import mistune

text = "Hello **world**"

markdown.markdown(text)          # '<p>Hello <strong>world</strong></p>'
MarkdownIt().render(text)        # '<p>Hello <strong>world</strong></p>\n'
mistune.html(text)               # '<p>Hello <strong>world</strong></p>\n'

Python-Markdown also exposes a Markdown class for repeated conversions. Build it once with your extensions, then call convert() per document and reset() between them so state from the toc or footnotes extension doesn't leak:

md = markdown.Markdown(extensions=["extra", "toc"])
html_one = md.convert(first_doc)
html_two = md.reset().convert(second_doc)

Reading from disk is ordinary Python. Open the .md file with encoding="utf-8", pass the string in, and write the result out. The full script near the end of this post does exactly that.

Where Do Python-Markdown, markdown-it-py, and mistune Differ?

Python-Markdown's own documentation says it "is not a CommonMark implementation; nor is it trying to be." It follows John Gruber's 2004 reference implementation. That single decision explains most of the surprises people hit. We ran the sample below through all three libraries and compared the output.

- outer
  - inner, indented two spaces

Some file_name_with_underscores.txt

| Tool | Spec |
|---|---|
| a | b |

Nested lists. markdown-it-py and mistune nest the second item under the first, as CommonMark requires. Python-Markdown outputs two sibling <li> elements because Gruber's rules want four spaces (or a tab) of indentation for a nested block. If your authors write two-space lists in VS Code or on GitHub, this is the difference you'll notice first.

Underscores inside words. Python-Markdown leaves file_name_with_underscores.txt alone by default. CommonMark parsers also leave it alone, but for a different reason (the underscore rule about word boundaries). Both agree here, though Python-Markdown still treats snake*case*word as emphasis, and so do the others.

Tables and fenced code. Neither the original Markdown spec nor CommonMark defines tables, so Python-Markdown and markdown-it-py print the pipe rows as a paragraph unless you turn tables on. Fenced code blocks are part of CommonMark, so markdown-it-py and mistune handle them out of the box. Python-Markdown needs the fenced_code extension, which extra includes.

Raw HTML. All three pass <script> tags straight through when called with defaults. mistune is the exception when you build a parser with mistune.create_markdown(), which escapes HTML unless you pass escape=False. That default is the reason the sanitising section below exists.

Task lists. None of the three renders - [ ] as a checkbox without help: mdit-py-plugins has tasklists_plugin, mistune has the task_lists plugin, and Python-Markdown has no official one. Our Markdown checkbox guide lists which platforms support the syntax.

Try the Sample in the Editor

The editor below holds the table, emphasis, and fenced-code parts of the sample (the nested-list case stays in the block above, because the embedded editor flattens leading spaces). Convert it to HTML and compare the result with the outputs above: the site's converter follows CommonMark plus GitHub extensions, so it matches markdown-it-py with tables enabled. The Markdown to HTML tool gives you the raw HTML to diff against your script's output.

Python Markdown sample

Rendered with Python-Markdown, markdown-it-py, and mistune.

  • outer item
  • file_name_with_underscores.txt stays plain
  • snakecaseword gets emphasis
Library Spec
markdown Gruber 2004
markdown-it-py CommonMark
mistune CommonMark-leaning
pip install markdown nh3

Compare this HTML with what your script prints.

57 words391 characters19 lines
Markdown

Which Python-Markdown Extensions Should You Enable?

Python-Markdown's extensions are named by short strings in the extensions list. The extra bundle turns on abbr, attr_list, def_list, fenced_code, footnotes, md_in_html, and tables in one go. Add toc for heading IDs and a [TOC] placeholder, and codehilite for syntax highlighting through Pygments.

import markdown

html = markdown.markdown(
    text,
    extensions=["extra", "toc", "codehilite"],
    extension_configs={
        "toc": {"toc_depth": "2-3"},
        "codehilite": {"css_class": "highlight"},
    },
)

codehilite wraps each fenced block in <div class="highlight"> and marks tokens with span classes, but it ships no colours. Run pygmentize -S default -f html -a .highlight > highlight.css once and link the file. The fence syntax itself is covered in our Markdown code block guide.

The official docs note that all bundled extensions are in maintenance mode: bugs get fixed, but no new behaviour is planned. That's stable rather than dead, and we prefer it for anything that has to render the same way for years.

For the markdown it python port, the equivalent is presets and plugins. MarkdownIt("gfm-like") gets you tables, strikethrough, and autolinks (install linkify-it-py first, or it raises an error). Footnotes and task lists come from mdit-py-plugins via .use(plugin). For mistune, pass plugin names to create_markdown(plugins=["table", "footnotes", "task_lists"]).

Sanitising User-Submitted Markdown with nh3

Markdown is a superset of HTML, so every converter above passes raw tags through on purpose. That is fine for your own README. It's a cross-site scripting hole the moment the Markdown comes from a comment form, a wiki, or an API.

The maintained sanitiser is nh3, a Python binding to the Ammonia library written in Rust. Bleach, which older tutorials recommend, carries a notice dated 5 June 2026 that it's no longer maintained and will get no further releases, including for security issues. Swap it out.

import markdown
import nh3

raw = '<script>alert(1)</script><a href="javascript:alert(1)" onclick="x()">x</a> **ok**'
html = markdown.markdown(raw, extensions=["extra"])
print(nh3.clean(html))
# <p><a rel="noopener noreferrer">x</a> <strong>ok</strong></p>

nh3 dropped the script tag, the javascript: href, and the onclick attribute, and added rel="noopener noreferrer" to the link. If you use codehilite or toc, extend the default allow list so their class and id attributes survive. The Django-specific version of this setup, with a template filter and allow list, is in our Django Markdown tutorial.

A Complete Python Markdown Script

This script reads a .md file, renders it with extensions, sanitises the result, and wraps it in a page with a stylesheet link. We use it as the build step for internal docs.

from pathlib import Path
import markdown
import nh3

ALLOWED_ATTRS = {
    **nh3.ALLOWED_ATTRIBUTES,
    "code": {"class"},
    "pre": {"class"},
    "div": {"class"},
    "span": {"class"},
    **{f"h{n}": {"id"} for n in range(1, 7)},
}

TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{title}</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="highlight.css">
</head>
<body>
<main>{body}</main>
</body>
</html>"""


def render(src: Path, dst: Path) -> None:
    text = src.read_text(encoding="utf-8")
    md = markdown.Markdown(extensions=["extra", "toc", "codehilite"])
    body = nh3.clean(md.convert(text), attributes=ALLOWED_ATTRS)
    title = src.stem.replace("-", " ").title()
    dst.write_text(TEMPLATE.format(title=title, body=body), encoding="utf-8")


if __name__ == "__main__":
    for path in Path("docs").glob("*.md"):
        render(path, Path("site") / f"{path.stem}.html")

Run it with python build.py from the folder that holds docs/. If you'd rather skip the sanitiser for trusted content, drop the nh3.clean() call and you have a 20-line static site generator.

Going the Other Way: HTML to Markdown in Python

Searches for a markdown python generator usually mean one of two things. The first is turning HTML back into Markdown. The markdownify package (version 1.2.3, June 2026) does that: from markdownify import markdownify as md, then md("<b>Yay</b>") returns **Yay**. The html2text package is the older alternative.

The second is generating Markdown from Python data, such as a table from a list of dicts. There's no library needed:

rows = [{"name": "markdown", "spec": "Gruber"}, {"name": "mistune", "spec": "CommonMark"}]
header = "| " + " | ".join(rows[0]) + " |"
divider = "|" + "---|" * len(rows[0])
body = "\n".join("| " + " | ".join(r.values()) + " |" for r in rows)
print("\n".join([header, divider, body]))

That prints a valid pipe table you can paste into a README or feed back into any of the three parsers with tables enabled.

Common Python Markdown Mistakes

Installing the wrong package. pip install python-markdown fails, and pip install markdown2 installs a different library. The package is markdown; the import is import markdown.

Forgetting extra and wondering where the table went. Python-Markdown and markdown-it-py both print pipe tables as a plain paragraph by default. Pass extensions=["tables"] (or extra), or enable table on the markdown-it-py instance.

Trusting the output. markdown.markdown() never sanitises. Rendering user content without nh3.clean() is the most common security bug in Python Markdown code we review. Escaping the input before parsing doesn't work either, because it also escapes the Markdown syntax.

Python Markdown FAQ

Rendering python markdown is a one-liner with any of the three libraries, but the parser you pick decides how nested lists, tables, and raw HTML come out. Pick Python-Markdown for mature extensions, markdown-it-py for CommonMark accuracy, and mistune for speed, and always run untrusted output through nh3. To see what a snippet should render as, paste it into the editor and compare. Our Markdown vs HTML post explains why the HTML step exists.