August 10, 2026
Markdown to HTML: what actually happens under the hood
Markdown looks like it's parsed in one pass, top to bottom, turning symbols into tags as it goes. It isn't, at least not in any parser that handles it correctly. Markdown parsing happens in two distinct stages: block-level structure first, then inline formatting second, applied only within the blocks that stage one already identified. Understanding that split explains most of the syntax rules that otherwise seem arbitrary.
Stage one: figuring out the blocks
Before a parser cares about bold text or links, it has to answer a more basic question: where does each chunk of the document start and end, and what kind of chunk is it? This first pass works on whole lines, grouped by blank-line separation. A run of consecutive non-blank lines is a paragraph, unless it matches a more specific pattern first: a line starting with # is a heading, a line starting with > is a blockquote, a line starting with -, *, or a number and a period is a list item, and a line of three backticks opens a fenced code block that continues until a matching closing fence. At this stage the parser is doing structural classification only. It doesn't yet look inside any line for **bold** or [a link](url), it's just deciding "this is a paragraph" or "this is a list."
Stage two: inline formatting, inside each block
Once the document is broken into typed blocks, the parser makes a second pass over the text inside each one, looking for inline patterns: **bold**, *italic*, `inline code`, and [link text](url). This is why inline syntax works inside a paragraph or a list item but is deliberately ignored inside a fenced code block: the code block was already classified as literal content in stage one, so stage two never runs its inline scan over it at all.
The blank-line gotcha
Because block boundaries are usually found by blank lines, a paragraph immediately followed by a list, with no blank line between them, doesn't reliably render as a paragraph plus a separate list in every parser. Some treat the whole thing as one paragraph, folding the list markers into ordinary text instead of recognizing a new block. The safe habit, and the one that behaves consistently across parsers, is to always leave a blank line between a paragraph and the list that follows it:
Here is my list:
- first item
- second itemis the version that risks getting merged into one paragraph in a stricter parser, while
Here is my list:
- first item
- second itemreliably produces a paragraph followed by a real, separate list.
Heading levels map directly to h1 through h6
The number of leading # characters sets the heading level one-to-one: a single # becomes <h1>, ## becomes <h2>, and so on down to ######, which becomes <h6>. There's no level beyond six, since HTML itself stops at <h6>, so a seventh # is generally just treated as a literal character rather than producing a deeper heading.
Why fenced code blocks don't get inline formatting
A fenced code block, opened and closed with triple backticks, exists specifically to preserve its contents literally. That's the whole reason it's a distinct block type in stage one rather than being left as an ordinary paragraph: once something is classified as a code block, stage two skips its inline scan entirely, so an asterisk used for multiplication in a code sample doesn't accidentally get read as the start of *italic*, and an underscore in a variable name doesn't get mistaken for emphasis either.
A small before and after
This Markdown input:
## Getting started
Install the package with `npm install`, then check **the docs** for
configuration options.
- Requires Node 18+
- No other dependenciesproduces this HTML:
<h2>Getting started</h2>
<p>Install the package with <code>npm install</code>, then check <strong>the docs</strong> for
configuration options.</p>
<ul>
<li>Requires Node 18+</li>
<li>No other dependencies</li>
</ul>Notice the two passes doing their separate jobs: the blank lines split the input into a heading, a paragraph, and a list first, and only then does the inline pass turn **the docs** into <strong> and the backtick-wrapped text into <code>, entirely within the paragraph block it belongs to.
Want to try it yourself?
Open the Markdown ↔ HTML Converter →