Stripping Markdown: What Plain Text Loses, and What a Regex Gets Wrong
Published 6/3/2025 · 12 min read · Text & language tools
Daniel Okonkwo — Front-end developer and tech writer at OneKitly
Web performance · File formats
Checked against 5 sources
Markdown keeps meaning in its punctuation, so deleting the syntax deletes information. Run a naive stripper over a short report and the losses are concrete: [full breakdown](https://example.com/q3.pdf) becomes "full breakdown" and the destination is gone with no trace that a link was ever there; a two-level list of regions and channels comes out flush left, so "Retail: up 4%" and "Retail: flat" no longer belong to any region; and a table becomes a run of words with its --- separator row surviving as literal text. Underneath sits a second problem: markdown has no single specification. CommonMark and GitHub Flavored Markdown disagree — a pipe table, ~~strikethrough~~, a bare URL and a task list are GFM extensions and stay literal text under CommonMark — and a regex stripper additionally mishandles cases a parser gets right. Verified in Node: a naive stripper turns report_final_v2.txt into reportfinalv2.txt and 2 * 3 * 4 into 2 3 4, and it edits the inside of a fenced code block, while a CommonMark parser leaves all three untouched. Keep link text plus its destination in parentheses, keep list markers as bullets and code-block contents verbatim; drop emphasis markers and heading hashes.
A link becomes text with its destination deleted, a nested list loses its hierarchy, a table becomes a row of words. Then the technical half: markdown has no single spec, and a regex stripper mangles a filename, a multiplication sign and the inside of a code block — all shown against a real parser.
Markdown keeps meaning in its punctuation
Markdown looks like plain text with decoration, which makes removing the decoration feel free. It is not. Three of its constructs carry information that lives nowhere else in the document. A link holds two things, the text and the destination, and only one of them is visible; delete the syntax and the destination goes with it. A nested list holds a relationship — this channel belongs to that region — encoded entirely as indentation. A table holds a grid, and a grid is two dimensions written into one dimension of text.
Take a short report: a level-two heading, a sentence containing a link, and a two-level list of regions and their channels. Run a naive stripper over it — the usual list of patterns for headings, emphasis, code spans, links and bullets — and it comes back about a third shorter, which sounds like a job well done until you read it. "See the full breakdown for the raw numbers" now points nowhere. "Retail: up 4%", "Online: up 11%" and "Retail: flat" are flush against "North America" and "Europe", at the same indentation, so nothing says which region each figure belongs to — and the document contains two different "Retail" lines that now contradict each other.
Markdown has no single specification
The original 2004 description was a Perl script and a page of prose, not a grammar, and the ambiguities were resolved differently by every implementation that followed. CommonMark exists to fix that: it is a precise specification with a test suite. GitHub Flavored Markdown is CommonMark plus four extensions, and those extensions are exactly the constructs people assume are part of markdown. Run the same input through a parser with the GFM extensions on and off and the difference is visible.
A pipe table becomes a real table under GFM and stays a paragraph of literal pipes under CommonMark. Two tildes around a word become a deletion under GFM and stay two tildes under CommonMark. A bare https:// address becomes a link under GFM and stays text under CommonMark. And a list item beginning with a bracketed space or x becomes a checkbox under GFM and keeps its literal brackets under CommonMark. Each of those is a construct your stripper will either recognise or not, depending on a setting nobody wrote down.
There is also a construct both specifications share that almost every regex stripper forgets: the setext heading, a line of text with a row of equals signs or hyphens underneath it. Both CommonMark and GFM turn it into a heading. A stripper whose only heading rule matches leading hash marks leaves the row of equals signs sitting in the output as a line of punctuation.
Three things a regex gets wrong and a parser gets right
First, the underscore in a filename. Run a naive stripper over "Open report_final_v2.txt and then archive_2026_q1.csv" and the pattern for underscore emphasis eats both: reportfinalv2.txt and archive2026q1.csv. A CommonMark parser leaves the sentence exactly as it was, because the specification says an underscore inside a word does not open or close emphasis — that rule exists precisely so that snake_case survives. The asterisk behaves differently from the underscore here, which is itself a rule a regex cannot express.
Second, the asterisk as an operator. "The area is 2 * width * height, so 2 * 3 * 4 = 24" comes out of the naive stripper as "The area is 2 width height, so 2 3 4 = 24", with every asterisk gone and a double space where it stood. The parser leaves it untouched, because CommonMark's flanking rules say a delimiter with whitespace on both sides can neither open nor close emphasis. The rule is precise, well documented and about three sentences long — and it is a rule about context, which is what regular expressions structurally cannot see.
Third, the reference-style link, whose destination is not next to the text at all. Write "See [the full report][rpt] for details" and put "[rpt]: https://example.com/2026-report.pdf" at the bottom of the document, and a parser resolves the two into one link. The naive stripper's link pattern expects a parenthesis, matches nothing, and leaves both the brackets in the sentence and the definition line at the foot of the document, so the output is worse than the input in two places at once. Nothing about that is fixable by adding one more pattern: resolving a reference link means holding state across the whole document, which is what a parser is.
Code blocks: the contents must survive untouched
A code block is the one place in a markdown document where markdown syntax is not markdown syntax. Its whole purpose is to hold characters that must be reproduced exactly, and those characters routinely include asterisks, underscores and hash marks. Feed a naive stripper a fenced Python block containing def f(*args), a comment line beginning with a hash mark, and an identifier written with underscores, and it does three separate wrong things: it eats one of the three fence backticks with its inline-code pattern, it strips the underscores from the identifier, and it leaves the fence remnant in the output. A parser marks the whole block as code and does not look inside it at all.
The indented code block is the same trap without the visible fence. Four spaces at the start of a line make a code block in both CommonMark and GFM, which means a stripper that only knows about backticks will happily rewrite the contents: an identifier written a_b_c comes out as abc, and a comment beginning with a hash mark is at the mercy of the heading rule. If you are writing the stripper yourself, detect code regions first and mask them, then run every other rule on what is left, then put the masked regions back verbatim. That is one more pass and it removes an entire class of failure.
What to keep, what to drop
The useful rule is not "remove the syntax" but "remove the syntax that only carried formatting, and rewrite the syntax that carried information". Drop heading hash marks and setext underlines, emphasis markers, code fences, blockquote arrows and horizontal rules: none of them says anything the words do not. Rewrite the rest. A link becomes its text followed by its destination in parentheses. A list marker becomes a bullet character, and the indentation stays, because that is where the hierarchy lives. A table becomes one line per row with a visible separator between cells, and the alignment row disappears. An image becomes its alt text, which is the only part of it that was ever words.
The same document treated that way keeps everything the naive pass lost. The heading is still a line of words, the sentence still carries its destination in parentheses, and the two-level list still has two levels, so the four figures still belong to the two regions. It is longer than the naive output, and that extra length is precisely the information the naive output deleted.
One shortcut is worth knowing, and one warning goes with it. If a real markdown parser is already available, the shortest correct route to plain text is to render the markdown to HTML and then extract the text from the HTML, because the parser has already resolved reference links, code blocks and the flanking rules for you. The warning is that the second half of that route is its own problem — stripping HTML has an order of operations of its own, covered in the companion article in this series, and doing it with a second regex reintroduces exactly the class of bug you just escaped.
| Construct | Naive regex output | What a parser does | Keep or drop |
|---|---|---|---|
| Inline link [text](url) | Text kept, destination deleted | Text and destination both available | Keep both: text, then the URL in parentheses |
| Underscores in a filename: report_final_v2.txt | reportfinalv2.txt | Unchanged: intra-word underscores are not emphasis | Keep |
| Asterisk as a multiplication sign: 2 * 3 * 4 | 2 3 4 | Unchanged: a delimiter with a space on both sides opens nothing | Keep |
| Fenced code block | Eats a fence backtick and edits the code inside | Contents preserved verbatim | Drop the fence, keep every character inside |
| Reference link [text][ref] | Brackets survive and the definition line survives too | Destination resolved from the definition elsewhere | Keep text and destination, drop the definition line |
| Pipe table | A run of words, with the --- row surviving as text | Rows and cells, and only under GFM — CommonMark has no tables | Keep the cells with a separator, drop the alignment row |
Frequently asked questions
- Where does the link URL go when markdown is stripped?
- Into nothing, in most strippers. The common pattern replaces the whole [text](url) construct with the captured text, so "See the [full breakdown](https://example.com/q3.pdf)" becomes "See the full breakdown" — a sentence that promises a destination it no longer has. If the plain text is for a human, keep the destination in parentheses after the text; if it is for a search index, keep the text and store the URL as a separate field. Deleting it silently is the one option that has no use case.
- Why did stripping markdown mangle my filenames?
- Because the stripper treated the underscores as emphasis. A pattern like an underscore, a lazy capture and another underscore matches the middle of report_final_v2.txt and deletes both underscores, giving reportfinalv2.txt. CommonMark deliberately does not do that: intra-word underscores never open or close emphasis, which is the rule that keeps snake_case identifiers and filenames intact. Any stripper that gets this wrong is not implementing markdown; it is implementing a guess about markdown.
- Is stripping markdown the same as converting to HTML and stripping the tags?
- It is a good route and not an identical one. Rendering to HTML with a real parser resolves reference links, code blocks and the emphasis rules correctly, which is most of the difficulty. But the second step throws away exactly what you wanted to keep unless you handle it: the href of an anchor, the alt of an image, the cell boundaries of a table. Extract those attributes deliberately before you take the text content, and remember that stripping HTML has its own order-of-operations problem, covered separately in this series.
- What happens to a table?
- Under a naive stripper it becomes a run of words: the pipes turn into spaces and the alignment row of hyphens survives as a line of punctuation, so a reader gets four columns of numbers with nothing saying which is which. Under a parser it is a grid you can re-render — one line per row, cells joined by a visible separator, alignment row discarded. Note that a table is not even markdown in the strict sense: it is a GFM extension, and under plain CommonMark those pipe lines are an ordinary paragraph.
- Does the stripper need to know about GFM extensions?
- If your documents come from a code host, an issue tracker or a chat tool, yes. Tables, strikethrough, bare-URL autolinks and task lists are GFM extensions, and a CommonMark-only stripper leaves their syntax in the output as literal characters — two tildes around a word, brackets around a space, rows of pipes. The reverse mistake also exists: applying GFM rules to a document written for a strict CommonMark renderer turns a paragraph of pipes into a table that its author never wrote. Pick the flavour to match the source, and write it down next to the code.
- Should code blocks be kept or removed?
- Keep their contents and remove only the fence. What must never happen is the middle option, where the fence goes and the rules for emphasis, headings and inline code then run over the code inside — that produces text that looks like code but is not the code that was written, which is worse than either extreme. If your plain text feeds a summariser or a search index and code adds noise, drop the block wholesale and leave a marker in its place. Editing the inside of a code block is the one thing with no defensible reading.
Articles you may find interesting
All guides →Related tools
Sources
- CommonMark — CommonMark Specification — emphasis and strong emphasis, code spans, fenced and indented code blocks, link reference definitions
- GitHub — GitHub Flavored Markdown Spec — tables, task list items, strikethrough and autolink extensions
- IETF — RFC 7763 and RFC 7764 — the text/markdown media type and known variants
- WHATWG — HTML Standard — parsing and the text content of elements
- MDN Web Docs — Node.textContent and the difference between text content and rendered text
Spotted a mistake in this article?