Skip to content
OneKitly

Stripping Markdown: What Plain Text Loses, and What a Regex Gets Wrong

Published 6/3/2025 · 12 min read · Text & language tools

Daniel Okonkwo

Daniel OkonkwoFront-end developer and tech writer at OneKitly

Web performance · File formats

Checked against 5 sources

View profile
In short

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.

Six markdown constructs put through a naive regex stripper and through a CommonMark parser, both run in Node 26.3. The parser column is what a reader would expect; the regex column is what a pattern list produces.
ConstructNaive regex outputWhat a parser doesKeep or drop
Inline link [text](url)Text kept, destination deletedText and destination both availableKeep both: text, then the URL in parentheses
Underscores in a filename: report_final_v2.txtreportfinalv2.txtUnchanged: intra-word underscores are not emphasisKeep
Asterisk as a multiplication sign: 2 * 3 * 42 3 4Unchanged: a delimiter with a space on both sides opens nothingKeep
Fenced code blockEats a fence backtick and edits the code insideContents preserved verbatimDrop the fence, keep every character inside
Reference link [text][ref]Brackets survive and the definition line survives tooDestination resolved from the definition elsewhereKeep text and destination, drop the definition line
Pipe tableA run of words, with the --- row surviving as textRows and cells, and only under GFM — CommonMark has no tablesKeep the cells with a separator, drop the alignment row
Strip MarkdownRemove Markdown formatting to get clean, plain text.Try the tool

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
How-toCleaning Messy Text: The Order of Operations That Actually MattersStrip tags before decoding entities, trim before deduplicating, collapse whitespace last. Three orderings run in Node, a nine-step pipeline in the right sequence, and the invisible characters — U+00A0, U+200B, U+FEFF — that survive every naive cleanup.GuideStripping HTML Safely: What a Tag Remover Can and Cannot DoRemoving tags and sanitising HTML are two different jobs. One real fragment run through a naive regex and through a formatting-aware stripper, with script and style contents, block breaks, comments, CDATA and entity order all shown as output.GuideFormatting Numbers for Six Languages: Separators, Currency and the Parse Back1,234.56 and 1.234,56 are the same number, and confusing them changes the value a reader parses. We ran Intl.NumberFormat for all six site locales and printed every separator — including the invisible one French uses — then measured why parseFloat cannot undo any of it.ExplainerEmoji Are Harder Than They Look: Why "Just Strip the Emoji" Has No One-Line AnswerOne visible emoji can be one code point or fourteen UTF-16 units. We ran three popular regexes against a real sentence and each broke differently — one deleted the digits. Here is why, which Unicode property answers which question, and the grapheme-cluster rule that actually works.GuideMarkdown Task Lists and What Actually Renders WhereTask lists are not in CommonMark. They are a GitHub Flavored Markdown extension, which is why the same file shows checkboxes in one place and literal brackets in another. The exact marker rule, what nesting does, and a table of what is CommonMark, what is GFM and what is neither — checked against both specs and four renderers.ExplainerCounting Words Is Ambiguous, and Every Tool Answers DifferentlyA word count is a definition, not a measurement. We counted the same paragraph four ways and got 25, 28, 33 and 38 — then counted 50,000 characters of ordinary prose and got agreement to within 4.5%. The gap is entirely driven by compounds, figures and URLs.

Related tools

Sources

Spotted a mistake in this article?