pyguides

Build a Markdown to HTML Converter in Python

Markdown powers much of the web — from README files to blog posts to documentation. Converting Markdown to HTML is a common task, and Python gives you excellent tools to do it well.

This guide walks through the full conversion pipeline: reading a Markdown file, parsing it to HTML, adding syntax highlighting for code blocks, sanitizing the output against XSS attacks, and wrapping it all in a working CLI tool.

The conversion pipeline

Every Markdown-to-HTML converter follows the same five-step pipeline:

read .md file
    → parse with a markdown library
    → (optional) apply syntax highlighting to code blocks
    → (optional) sanitize HTML to strip dangerous tags/attributes
    → wrap in an HTML template
    → write to output file

The two optional steps — highlighting and sanitization — are where most real-world converters spend their logic. The parsing step itself is straightforward; it’s the surrounding concerns that require care.

Setting Up

Install the dependencies you need for this guide:

pip install mistune markdown pygments bleach
  • mistune — fast pure-Python markdown parser
  • markdown — the classic Python-Markdown library with a rich extension ecosystem
  • pygments — syntax highlighting engine used by both libraries
  • bleach — HTML sanitizer for stripping dangerous tags

Converting Markdown with mistune

mistune is the simplest library for converting Markdown to HTML. Its API is a single function call. Pass your markdown text to mistune.html() and it returns a string of raw HTML:

import mistune

md = """
## Heading

This is **bold** and this is *italic*.

- Item one
- Item two
"""

html = mistune.html(md)
print(html)
<h2>Heading</h2>
<p>This is <strong>bold</strong> and this is <em>italic</em>.</p>
<ul>
<li>Item one</li>
<li>Item two</li>
</ul>

mistune.html() handles tables, strikethrough, task lists, and autolinks out of the box. No extensions to configure for basic parsing.

The output you see above demonstrates how mistune translates common Markdown constructs. Headings become <h2> tags, bold text wraps in <strong>, italic in <em>, and list items are enclosed in <ul> with <li> children. This is essentially a one-to-one mapping from Markdown syntax to their HTML equivalents, which makes the output predictable and easy to style with CSS.

Converting markdown with Python-Markdown

The markdown library takes a different approach: a lean core with an extensions system. Enable features by passing extension names:

import markdown

md = """
## Heading

This is **bold** text.

> A blockquote for emphasis.
"""

html = markdown.markdown(md)
print(html)
<h2>Heading</h2>
<p>This is <strong>bold</strong> text.</p>
<blockquote>
<p>A blockquote for emphasis.</p>
</blockquote>

The output shows Python-Markdown producing standard HTML with <blockquote> tags. Unlike mistune, which activates features by default, Python-Markdown starts minimal and requires you to opt into each feature through its extension system. This design lets you keep the parser lean when you only need basic formatting.

For fenced code blocks, add the fenced_code extension. For syntax highlighting, also add codehilite:

import markdown

md = """
Here's some Python:

    def add(a, b):
        return a + b

And a JavaScript example:

    const add = (a, b) => a + b;
"""

html = markdown.markdown(md, extensions=['fenced_code', 'codehilite'])
print(html)

Python-Markdown requires explicit extension activation. The codehilite extension adds CSS classes to code blocks — include a Pygments stylesheet in your HTML output to see colors.

Adding syntax highlighting with Pygments

Neither mistune nor Python-Markdown includes syntax highlighting built-in. Both use Pygments for code block coloring, but they integrate differently.

Highlighting with mistune

mistune requires a custom renderer. Subclass mistune.renderers.html.HTMLRenderer and override the block_code method:

import mistune
from pygments import highlight
from pygments.lexers import get_lexer_by_name, TextLexer
from pygments.formatters import HtmlFormatter


class HighlightRenderer(mistune.renderers.html.HTMLRenderer):
    def block_code(self, code, lang=None):
        if lang:
            lexer = get_lexer_by_name(lang, stripall=True)
        else:
            lexer = TextLexer()
        formatter = HtmlFormatter()
        highlighted = highlight(code, lexer, formatter)
        return f'<div class="highlight">{formatter.get_style_defs()}{highlighted}</div>\n'


renderer = HighlightRenderer()
markdown = mistune.create_markdown(renderer=renderer)

md = """
Here is Python:

    def multiply(a, b):
        return a * b

And JavaScript:

    const multiply = (a, b) => a * b;
"""

print(markdown(md))

This renderer overrides mistune’s default code-block handler to inject syntax-highlighted HTML. The block_code method receives the raw code string and optional language identifier. It looks up the appropriate Pygments lexer, highlights the code, and returns the formatted HTML. By subclassing the renderer rather than monkey-patching, you keep the highlighting logic cleanly separated from the parsing logic, making it easy to swap in a different highlighter later.

Highlighting with Python-Markdown

Python-Markdown’s codehilite extension handles highlighting automatically:

import markdown

md = """
Python:

    def add(a, b):
        return a + b

JavaScript:

    const add = (a, b) => a + b;
"""

html = markdown.markdown(md, extensions=['codehilite', 'fenced_code'])
print(html)

Python-Markdown’s codehilite extension wraps each code block in a <div class="codehilite"> container and adds <span> elements with CSS classes for each token type — keywords, strings, comments, and so on. The HTML is structurally ready for coloring; you just need to supply the CSS rules that map those classes to actual colors.

To actually see colors in the browser, include a Pygments stylesheet in your HTML template:

from pygments.formatters import HtmlFormatter

def get_pygments_css():
    formatter = HtmlFormatter(style='monokai')
    return f'<style>{formatter.get_style_defs(".codehilite")}</style>'

Sanitizing HTML Output

Raw Markdown conversion is not safe for untrusted input. Markdown parsers pass through raw HTML, which means a user can inject <script> tags or event handlers like <img onerror="...">.

Render untrusted Markdown through a sanitizer before outputting HTML. This is not optional — it is a security requirement.

Using bleach

bleach.clean() strips dangerous tags and attributes while preserving safe HTML:

import bleach

dirty = """
<p>Hello <script>alert("xss")</script></p>
<p>Click <a href="javascript:alert(1)">here</a></p>
<p><img src=x onerror="alert(1)"></p>
"""

clean = bleach.clean(
    dirty,
    tags=['p', 'a'],
    attributes={'a': ['href']},
    strip=True
)
print(clean)
<p>Hello alert("xss")</p>
<p>Click <a>here</a></p>
<p></p>

The strip=True option removes disallowed tags entirely, stripping the <script> element and the <img> tag with its onerror handler. The <a> tag is preserved but the javascript: URL in its href is neutralized, leaving just the anchor text. This is the minimum safety net you need when accepting Markdown from untrusted sources like user comments or public-facing forms.

Full Pipeline: mistune to bleach

Combine parsing and sanitization into a single conversion function:

import mistune
import bleach


def convert_md_to_html(md_text, sanitize=True):
    raw_html = mistune.html(md_text)

    if not sanitize:
        return raw_html

    return bleach.clean(
        raw_html,
        tags=[
            'h1', 'h2', 'h3', 'h4', 'p', 'ul', 'ol', 'li',
            'strong', 'em', 'b', 'i', 'u', 's',
            'a', 'code', 'pre', 'blockquote',
            'div', 'span', 'br', 'hr',
            'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
        ],
        attributes={
            'a': ['href', 'title', 'target', 'rel'],
            'img': ['src', 'alt', 'title', 'width', 'height'],
            'code': ['class'],
            'span': ['class'],
            'div': ['class'],
            '*': ['id'],
        },
        strip=True
    )


This function wraps both parsing and sanitization into a single call. When `sanitize=True` (the default), bleach strips script tags, event handlers, and javascript URLs before the HTML ever reaches a browser. The allowed tags list covers common Markdown output — headings, paragraphs, lists, inline formatting, links, code blocks, and images. You can tighten the list further for stricter security, removing `img` or `div` tags if your Markdown content should never include them.

# Test with a potentially dangerous input
md_input = """
A Safe Article

Click [here](https://example.com)!

<script>document.cookie</script>

    print("safe")
"""

result = convert_md_to_html(md_input)
print(result)
<h2>A Safe Article</h2>
<p>Click <a href="https://example.com" rel="nofollow">here</a>!</p>
<p></p>
<pre><code>print("safe")
</code></pre>

The script tag is stripped, the link is preserved with rel="nofollow" set by bleach’s default configuration, and the code block is rendered safely as <pre><code>. Notice that mistune wraps code blocks in <pre><code> rather than the <div class="highlight"> our custom renderer produces — this output uses the default mistune.html() call, not the highlighted renderer, to keep the pipeline simple. In production you would wire the highlighted renderer into the same function.

Building a full CLI converter

Now assemble the pieces into a reusable command-line tool. This project structure keeps concerns separate:

markdown_converter/
├── converter/
│   ├── __init__.py
│   ├── core.py
│   ├── renderer.py
│   └── sanitizer.py
├── main.py
└── requirements.txt

converter/renderer.py

import mistune
from pygments import highlight
from pygments.lexers import get_lexer_by_name, TextLexer
from pygments.formatters import HtmlFormatter


class HighlightRenderer(mistune.renderers.html.HTMLRenderer):
    def block_code(self, code, lang=None):
        if lang:
            lexer = get_lexer_by_name(lang, stripall=True)
        else:
            lexer = TextLexer()
        formatter = HtmlFormatter()
        highlighted = highlight(code, lexer, formatter)
        return f'<div class="highlight">{highlighted}</div>\n'

converter/sanitizer.py

import bleach

ALLOWED_TAGS = [
    'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
    'p', 'ul', 'ol', 'li',
    'strong', 'em', 'b', 'i', 'u', 's',
    'a', 'code', 'pre', 'blockquote',
    'div', 'span', 'br', 'hr',
    'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
]

ALLOWED_ATTRIBUTES = {
    'a': ['href', 'title', 'target', 'rel'],
    'img': ['src', 'alt', 'title', 'width', 'height'],
    'code': ['class'],
    'span': ['class'],
    'div': ['class'],
    '*': ['id'],
}


def sanitize_html(raw_html):
    return bleach.clean(
        raw_html,
        tags=ALLOWED_TAGS,
        attributes=ALLOWED_ATTRIBUTES,
        strip=True
    )

converter/core.py

import mistune
from .renderer import HighlightRenderer
from .sanitizer import sanitize_html


def convert_md_to_html(md_text, sanitize=True):
    renderer = HighlightRenderer()
    markdown = mistune.create_markdown(renderer=renderer)
    raw_html = markdown(md_text)

    if sanitize:
        return sanitize_html(raw_html)
    return raw_html


def convert_file(input_path, output_path, sanitize=True):
    with open(input_path, 'r', encoding='utf-8') as f:
        md_text = f.read()

    html = convert_md_to_html(md_text, sanitize=sanitize)
    full_page = wrap_in_html_template(html)

    with open(output_path, 'w', encoding='utf-8') as f:
        f.write(full_page)

    return output_path


def wrap_in_html_template(body_content, title="Document"):
    return f"""<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{title}</title>
    <style>
        body {{ font-family: system-ui, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 2rem; }}
        .highlight {{ background: #f4f4f4; padding: 1rem; overflow-x: auto; border-radius: 4px; }}
        pre {{ margin: 0; }}
    </style>
</head>
<body>
{body_content}
</body>
</html>"""

main.py

import argparse
import sys
from converter.core import convert_file


def main():
    parser = argparse.ArgumentParser(description='Convert Markdown to HTML')
    parser.add_argument('input', help='Input Markdown file')
    parser.add_argument('-o', '--output', help='Output HTML file', default=None)
    parser.add_argument(
        '--no-sanitize',
        action='store_true',
        help='Skip HTML sanitization'
    )
    args = parser.parse_args()

    output = args.output or args.input.replace('.md', '.html')

    try:
        result = convert_file(args.input, output, sanitize=not args.no_sanitize)
        print(f"Written: {result}")
    except FileNotFoundError:
        print(f"Error: '{args.input}' not found", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == '__main__':
    main()

Run it from the command line:

python main.py article.md -o article.html
Written: article.html

With --no-sanitize, the converter skips the bleach step — useful when you control the input and want maximum fidelity, but dangerous for user-submitted content.

Summary

You now have a complete picture of converting Markdown to HTML in Python:

StepWhat happensKey tool
ReadLoad .md filebuilt-in open()
ParseConvert Markdown syntax to HTMLmistune.html() or markdown.markdown()
HighlightColorize code blocksPygments via custom renderer or codehilite
SanitizeStrip dangerous HTML/attributesbleach.clean()
OutputWrap in HTML template and writestring formatting

mistune is the faster, simpler choice for new projects. Python-Markdown’s extension ecosystem is valuable when you need TOC generation, footnotes, or SmartyPants processing. Either way, make sanitization a mandatory step for any untrusted input.

The CLI tool gives you a reusable command for batch conversions or integration into static site generators. Adapt the project structure to your needs — swap the renderer for a different highlighting theme, tighten the allowed tags list for stricter security, or add a watch mode for live preview.

See Also

  • working-with-files — read and write files on disk, which is the first step in the conversion pipeline
  • regex-guide — pattern matching and text processing that complements markdown parsing
  • requests-library — fetch remote Markdown files over HTTP before converting them to HTML