markdownify
markdownify converts HTML into Markdown. You call md(html) and get a string back: headings become headings, <b> becomes **bold**, <a> becomes a link, lists and tables become their Markdown equivalents. It parses with Beautiful Soup and walks the tree, so it copes with the malformed markup real pages ship. Everything is tunable through keyword arguments (heading style, bullet characters, escaping, wrapping) and anything not covered can be overridden by subclassing MarkdownConverter and writing a convert_<tag> method.
The most predictable HTML to Markdown converter in Python, and the right pick when you already know which HTML you want converted. Pair it with something that finds the article first, because markdownify will faithfully convert your navigation bar too.
Use it if
- You are feeding web pages to an LLM and want token-cheap Markdown instead of raw HTML, with links and structure preserved
- You are migrating a CMS: old posts live as HTML blobs and need to become Markdown files with headings, lists, and tables intact
- You need control over the output dialect: ATX vs setext headings, asterisk vs underscore emphasis, which bullet characters nest at which depth
- You have one tag that needs special treatment (images, callout divs, syntax-highlighted pre blocks) and want to override just that with a MarkdownConverter subclass
- You already have a Beautiful Soup tree from scraping and want to convert a selected subtree with convert_soup rather than re-parsing a string
- You want the article and not the page: markdownify converts everything it is given, so <nav>menu</nav><main>body</main> comes out as 'menubody'. Boilerplate removal is your job, or use trafilatura, which does extraction and conversion together
- Your HTML has nested tables: Markdown has no syntax for them and markdownify flattens the inner table into pipe characters inside a cell, producing output that renders as garbage
- You need PDF, DOCX, or PPTX alongside HTML: markitdown handles a whole family of formats behind one call, where this is HTML only
- You want plain readable text rather than Markdown: html2text has decades of tuning for line wrapping, link footnotes, and ignoring images
- Round-tripping matters: form controls, iframes, checkbox inputs inside list items, and <ins> tags are dropped or flattened with no warning, so converted content is lossy in ways you only notice in review
Setup reality
pip install markdownify is pure Python and pulls beautifulsoup4 plus six, which is still a runtime dependency in 2026 for a single six.text_type call. The default parser is html.parser from the standard library, which keeps the install light but is the least forgiving option on broken markup; switching to lxml or html5lib means installing them yourself and passing bs4_options. Two defaults surprise nearly everyone: heading_style is UNDERLINED, so <h1> comes out as setext underlining rather than a leading hash, and escape_asterisks and escape_underscores are both on, so ordinary prose containing a_b_c gets backslashes. If you are upgrading from 0.x, 1.0.0 changed every convert_<tag> signature from convert_as_inline to parent_tags, so custom converters need rewriting.
Patterns
Convert an HTML string to Markdownconvert-html
from markdownify import markdownify as md
md('<b>Yay</b> <a href="http://github.com">GitHub</a>')
# '**Yay** [GitHub](http://github.com)'
md('<h1>Title</h1><p>Hello <b>world</b></p>')
# 'Title\n=====\n\nHello **world**'The convenience alias is markdownify, but importing it as md is the convention in every example. Leading and trailing blank lines are stripped by default because strip_document defaults to STRIP.
Get hash-style headings instead of underlinesatx-headings
from markdownify import markdownify as md, ATX, ATX_CLOSED
md('<h1>Title</h1>', heading_style=ATX) # '# Title'
md('<h2>Title</h2>', heading_style=ATX_CLOSED) # '## Title ##'
md('<h1>Title</h1>') # 'Title\n====='The default is UNDERLINED (setext), which only exists for h1 and h2, so deeper headings quietly fall back to hashes and your document ends up with two styles. Set ATX once and forget it.
Drop tags or allow only a whiteliststrip-or-keep-tags
md('<b>Yay</b> <a href="http://github.com">GitHub</a>', strip=['a'])
# '**Yay** GitHub'
md('<b>Yay</b> <a href="http://github.com">GitHub</a>', convert=['b'])
# '**Yay** GitHub'strip and convert are mutually exclusive and passing both raises ValueError. Either way the tag's text content survives; only the Markdown markup is skipped, so this is not a way to delete content.
Stop backslashes appearing in ordinary prosecontrol-escaping
md('<p>a_b*c</p>')
# 'a\\_b\\*c'
md('<p>a_b*c</p>', escape_asterisks=False, escape_underscores=False)
# 'a_b*c'
md('<p>see - and # in text</p>', escape_misc=True)Escaping is correct but noisy, and it is the top complaint about the output. Turning it off is safe when the destination renderer is lenient or the text is going to a language model rather than a Markdown parser.
Annotate fenced code blocks with a languagecode-blocks
md('<pre><code>x = 1</code></pre>', code_language='python')
# '```python\nx = 1\n```'
def lang_from_pre(el):
code = el.find('code')
if code and code.has_attr('class'):
return code['class'][0].removeprefix('language-')
return None
md(html, code_language_callback=lang_from_pre)The callback receives the <pre> element, not the <code> inside it. Almost every syntax highlighter puts the language class on <code>, so a naive el['class'] callback returns None and you get an unlabelled fence.
Convert a table that has no header rowtables
html = '<table><tr><td>1</td><td>2</td></tr><tr><td>3</td><td>4</td></tr></table>'
md(html)
# '| | |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |'
md(html, table_infer_header=True)
# '| 1 | 2 |\n| --- | --- |\n| 3 | 4 |'Without table_infer_header you get an empty header row, which some renderers show as a blank band. colspan is padded out with empty cells; nested tables have no Markdown representation at all and come out as pipe soup.
Convert a Beautiful Soup subtree you already haveconvert-soup
import requests
from bs4 import BeautifulSoup
from markdownify import MarkdownConverter, ATX
soup = BeautifulSoup(requests.get(url, timeout=10).content, 'html.parser')
article = soup.select_one('article') or soup.body
markdown = MarkdownConverter(heading_style=ATX).convert_soup(article)This is the pattern that keeps navigation and footers out of the output: select the content node with Beautiful Soup first, then convert only that. convert_soup takes a node, convert takes a string.
Override the conversion for one tagcustom-converter
from markdownify import MarkdownConverter
class ImageBlockConverter(MarkdownConverter):
def convert_img(self, el, text, parent_tags):
return super().convert_img(el, text, parent_tags) + '\n\n'
def convert_p(self, el, text, parent_tags):
if 'callout' in (el.get('class') or []):
return f'\n> {text.strip()}\n\n'
return super().convert_p(el, text, parent_tags)
markdown = ImageBlockConverter(heading_style='ATX').convert(html)The third parameter is parent_tags, a set of ancestor tag names, and it replaced convert_as_inline in 1.0.0. A 0.x converter written against the old signature raises a TypeError the first time that tag appears.
Control how <br> and long paragraphs are renderedline-breaks-and-wrapping
md('a<br>b') # 'a \nb' two trailing spaces
md('a<br>b', newline_style='BACKSLASH') # 'a\\\nb'
md(long_html, wrap=True, wrap_width=80)
md(long_html, wrap=True, wrap_width=None) # reflow to one long lineThe default two-space line break is invisible in a diff and many editors strip trailing whitespace on save, which silently destroys the break. BACKSLASH survives that, at the cost of being non-standard Markdown.
Swap the Beautiful Soup parser for messy HTMLchoose-parser
md(html, bs4_options='lxml')
md(html, bs4_options=['html5lib'])
md(html, bs4_options={'features': 'lxml', 'from_encoding': 'iso-8859-8'})The default is html.parser, which needs no extra install but mis-nests badly broken markup. A string or list is treated as the features argument; a dict is passed straight through as BeautifulSoup kwargs.
Pick bullet characters and check nestingbullets-and-lists
md('<ul><li>a<ul><li>b</li></ul></li></ul>')
# '* a\n + b'
md('<ul><li>a<ul><li>b</li></ul></li></ul>', bullets='-')
# '- a\n - b'
md('<ol><li>a</li><li>b</li></ol>')
# '1. a\n2. b'The default bullets='*+-' rotates the marker by nesting depth, which is valid but looks inconsistent in a diff. Pass a single character to use it at every level.
Convert files from the shellcli-usage
$ markdownify example.html > example.md
$ cat example.html | markdownify --heading-style ATX > example.md
$ markdownify -hThe CLI exposes the same options as the function with dashed names. It reads stdin when no file is given, which makes it easy to drop into a curl pipeline.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| trafilatura | PyPI | You are scraping and need main-content extraction plus metadata, not just a format conversion. |
| html2text | PyPI | You want the older, heavily tuned converter with strong control over wrapping and link handling. |
| markitdown | PyPI | Your inputs include PDF, DOCX, XLSX, or images and you want one converter for all of them. |
| html-to-markdown | PyPI | You want a typed, actively rewritten fork of this API with a modern packaging setup. |