mrkeyoor.com_
Thu 06 Aug 07:40 UTC
PyPIUtilsupdated 06 Aug 2026

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.

Verdict

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.

API stability3/5The markdownify() entry point and its option names have been steady for years, but 1.0.0 in February 2025 changed the signature of every convert_<tag> method from convert_as_inline to parent_tags, which silently breaks custom converters, and escaping defaults shifted across the 0.14 series.
Docs3/5One reStructuredText README documents every option in a definition list and shows custom converters and the CLI, which is enough to get going; there is no hosted documentation, no reference for the conversion methods you would override, and no discussion of what the converter drops.
Maintenance4/5Four releases between February 2025 and June 2026 with a steady stream of outside contributors, py.typed added in 1.2.2 and type fixes in 1.2.3; 29 open issues and 13 open PRs on 2,235 stars is an ordinary backlog, not a stalled one.
Ecosystem4/520.7M weekly downloads, heavily pulled into scraping and retrieval pipelines that want Markdown for language models, and it composes directly with Beautiful Soup trees. It sits alongside html2text and trafilatura rather than displacing them.

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
Skip it if

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 line

The 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 -h

The 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

PackageRegistryPick it when
trafilaturaPyPIYou are scraping and need main-content extraction plus metadata, not just a format conversion.
html2textPyPIYou want the older, heavily tuned converter with strong control over wrapping and link handling.
markitdownPyPIYour inputs include PDF, DOCX, XLSX, or images and you want one converter for all of them.
html-to-markdownPyPIYou want a typed, actively rewritten fork of this API with a modern packaging setup.