Skip to content

Parse HTML with regex: when it works and when to use a parser

The common link regex was right on 2 of 13 inputs in our test, judged by Chrome's parser. Why regex fails on HTML, where it fits, and how much faster it is.

HProxy TeamOctober 12, 2026Updated October 12, 20267 min read

Parse HTML with regex: when it works and when to use a parser

Can you parse HTML with a regular expression? You can search HTML with one, but whether it finds the right things is another question, so we measured it.

On 12 October 2026 we gave 13 short pieces of HTML to four regular expressions and to two parsers, BeautifulSoup and lxml. Chrome's own HTML parser decided which links each piece really contains. The test ran on our server with Python 3.12.3, beautifulsoup4 4.15.0, lxml 6.1.3 and Chrome 143.

The short answer

  • The simplest link pattern, <a href="(.*?)", was right on 2 of the 13 inputs.
  • A regex we hardened rule by rule was right on 12. The 13th input still broke it.
  • BeautifulSoup and lxml were right on all 13.
  • The regular expressions were 9 to 39 times faster than lxml. The fastest one found only half the links.
  • Regex is the right tool for text that a parser has already pulled out of the page.

The test: 13 inputs, judged by Chrome

Each input is one short piece of HTML, such as a link in single quotes or a link inside a comment. Chrome's DOMParser read each one first. MDN describes it plainly. "The DOMParser interface provides the ability to parse XML or HTML source code from a string into a DOM Document". The links Chrome found count as the right answer. Then every method read the same piece.

Terminal output: 13 HTML inputs read by four regular expressions, BeautifulSoup and lxml and judged against Chrome's parser, then the speed of each method on a page of 487,586 bytes
Our own capture, 12 October 2026: regex_tests.py on our server, steps 1 to 3

R1 is the simplest link pattern, and R2 drops the <a to look for href anywhere. R3 allows other attributes first, single quotes and a line break inside the tag. R4 is a hardened regex, shown further down. In the table, a method "missed" when it found nothing where Chrome found a link. It found an "extra" when it also returned an address that is not a link.

What a regex misses

Five links, each written in a way the HTML standard allows, show the problem. A regex and two parsers read the same five.

import re

import lxml.html
from bs4 import BeautifulSoup

html = """<a class="nav" href="/pricing">Pricing</a>
<a href='/docs'>Docs</a>
<A HREF="/blog">Blog</A>
<!-- <a href="/old-page">Old</a> -->
<a href="/search?q=proxy&amp;page=2">Next</a>"""

print(re.findall(r'<a href="(.*?)"', html))
print([a["href"] for a in BeautifulSoup(html, "lxml").find_all("a", href=True)])
print(lxml.html.fromstring(html).xpath("//a/@href"))
['/old-page', '/search?q=proxy&amp;page=2']
['/pricing', '/docs', '/blog', '/search?q=proxy&page=2']
['/pricing', '/docs', '/blog', '/search?q=proxy&page=2']

The regex returned a page that was commented out. It missed three of the four real links, and the fourth still has &amp; in it. Each miss comes from a rule in the HTML standard.

  • Letter case. The standard says "tag names are case-insensitive". "In the HTML syntax, attribute names, even those for foreign elements, may be written with any mix of ASCII lower and ASCII upper alphas". So <A HREF="/blog"> is a link.
  • Quotes and spaces. A value can sit in double quotes, single quotes or no quotes. The standard lists an "Unquoted attribute value syntax" and a "Single-quoted attribute value syntax". It also allows spaces on both sides of the equals sign.
  • Order. Attributes can come in any order, so class can stand before href.
  • Comments. Everything between <!-- and --> is a comment, not markup.
  • Scripts. The standard lists script and style as raw text elements. What they hold is text, so a link written inside a script is not a link.
  • Entities. "Attribute values are a mixture of text and character references". &amp; in an address stands for &, and a regex returns it as written.

Greedy, lazy and nested

The next trap sits in the pattern itself, and Python's documentation warns about it. "The '*', '+', and '?' quantifiers are all greedy; they match as much text as possible". In its own example, <.*> run on <a> b <c> matches the whole string. Adding ? makes a quantifier lazy, and lazy has a problem of its own.

import re

html = "<h2>Plans</h2><p>Text</p><h2>Questions</h2>"
print(re.findall(r"<h2>(.*)</h2>", html))
print(re.findall(r"<h2>(.*?)</h2>", html))

nested = "<div><div>inner</div>outer</div>"
print(re.findall(r"<div>(.*?)</div>", nested))
['Plans</h2><p>Text</p><h2>Questions']
['Plans', 'Questions']
['<div>inner']

The greedy pattern swallowed both headings and the paragraph between them. The lazy one fixed that, but on the nested div it stopped at the first </div>, which belongs to the inner div. To pair each closing tag with its opening tag, a program has to count how deep it is. Python's re cannot keep that count, while a parser builds a tree and always knows.

This is the reason behind the best-known answer on the subject, posted on Stack Overflow on 13 November 2009. It opens with "You can't parse [X]HTML with regex. Because HTML can't be parsed by regex". Its short reason is "HTML is not a regular language and hence cannot be parsed by regular expressions". On 12 October 2026 it had a score of 4,394.

Fixing the regex, one rule at a time

Every miss in the test can be patched. R4 is our attempt. It removes comments and script and style blocks first. Then it ignores letter case and allows spaces around the equals sign. It accepts double quotes, single quotes or none, and it skips data-href. At the end, Python's html.unescape() turns &amp; back into &.

import re
from html import unescape

COMMENT = re.compile(r"<!--.*?-->", re.S)
RAW_TEXT = re.compile(r"<(script|style)\b.*?</\1\s*>", re.S | re.I)
LINK = re.compile(r"""<a\s[^>]*?(?<![\w-])href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))""", re.I)


def links(html):
    html = RAW_TEXT.sub("", COMMENT.sub("", html))
    return [unescape(a or b or c) for a, b, c in LINK.findall(html)]


print(links("<A HREF = '/search?q=proxy&amp;page=2'>Next</A>"))
print(links('<a title="1 > 0" href="/m">M</a>'))
['/search?q=proxy&page=2']
[]

R4 was right on 12 of our 13 inputs. The 13th has a > inside a quoted title, which is valid HTML. [^>]*? stops at that > before it reaches href, so the link is lost. Fixing that means reading quoted values properly, and that is what a parser does. Every patch adds one rule the HTML standard already has, and a parser follows all of them.

In Python the usual parsers are BeautifulSoup and lxml. "Beautiful Soup is a Python library for pulling data out of HTML and XML files", says its documentation. Both were right on all 13 inputs.

Is regex faster?

Yes, by a lot. We timed every method on one page of 3,000 product cards, 487,586 bytes with 6,000 links. The times are CPU time, the middle of five runs.

Milliseconds to find every link on a page of 487,586 bytes
BeautifulSoup + lxml
329.3 ms
lxml
35.4 ms
R4, hardened regex
3.9 ms
R3
1.3 ms
R2
1.3 ms
R1
0.9 ms
Source: HProxy test on our server, 12 October 2026: Python 3.12.3, beautifulsoup4 4.15.0, lxml 6.1.3; CPU time, middle of 5 runs on one page of 3,000 product cards · hproxy.comProxy.

R1 was the fastest and found 3,000 of the 6,000 links. Each card had a second link with class before href, and R1 skipped every one of them. R3 found all 6,000, because every link on this page used double quotes. It was still wrong on 7 of our 13 inputs.

lxml read the whole page in 35.4 ms, about 9 times faster than BeautifulSoup with lxml. If parsing speed matters, use lxml on its own or one of the faster parsers in our libraries test.

Where a regex does fit

Regular expressions are good at patterns inside text, such as prices, dates and order numbers. Let a parser find the element, then let the regex read its text.

import re

from bs4 import BeautifulSoup

html = '<div class="product"><h2>Laptop</h2><p class="price">Price: £1,299.00 (was £1,499.00)</p></div>'
soup = BeautifulSoup(html, "lxml")
text = soup.select_one("p.price").get_text()
prices = [float(p.replace(",", "")) for p in re.findall(r"£([\d,]+\.\d{2})", text)]
print(text)
print(prices)
Price: £1,299.00 (was £1,499.00)
[1299.0, 1499.0]

The parser deals with the HTML: tags, quotes, entities and nesting. The regex only sees one short string with no markup left in it. A quick regex search is also fine in HTML you wrote yourself, as long as you check what it missed.

Through a proxy

Neither a regex nor a parser downloads anything. Fetch the page first, for example with requests through a proxy, as our guide to proxies with Python requests shows. Then hand the text to BeautifulSoup or lxml. Our BeautifulSoup tutorial covers the path from download to data.

What this page could not check

  • The 13 inputs are short pieces we wrote. Real pages break in more ways, and some break parsers too.
  • One browser, Chrome 143, decided the right answers.
  • One version each: Python 3.12.3 with its re module, beautifulsoup4 4.15.0 and lxml 6.1.3. We did not test the third-party regex module.
  • The speed test used one page of our own. Other pages give other times.
  • R4 is our own pattern. A better one may exist, but the 13th input shows how each patch meets the next case.
  • We will rerun these tests by 12 January 2027.

Sources

  • WHATWG, HTML Living Standard, the HTML syntax: html.spec.whatwg.org.
  • MDN, DOMParser: developer.mozilla.org.
  • Python 3.12 documentation, re: docs.python.org.
  • Python 3.12 documentation, html.unescape: docs.python.org.
  • Beautiful Soup documentation: crummy.com.
  • Stack Overflow, "RegEx match open tags except XHTML self-contained tags", the answer of 13 November 2009: stackoverflow.com. Read through the Stack Exchange API on 12 October 2026.
  • Our own tests on our server, 12 October 2026: regex_tests.py at 06:20 UTC and the code on this page at 06:23 UTC, on HTML of our own.

Frequently asked questions

Can you parse HTML with regex?

You can search HTML with a regular expression, but it does not understand HTML. In our test, the simplest link pattern was right on 2 of 13 small inputs, judged by Chrome's own parser. BeautifulSoup and lxml were right on all 13.

Why does regex fail on HTML?

HTML has rules a simple pattern ignores. Tag names can be in any case, and attribute values can use double quotes, single quotes or none. A link inside a comment or a script is not a link. A regex also cannot count nested tags, so it cannot tell which closing tag belongs to which opening tag.

Is regex faster than BeautifulSoup?

Yes. On a page of 487,586 bytes with 6,000 links, our regular expressions took 0.9 to 3.9 ms of CPU time. lxml took 35.4 ms and BeautifulSoup with lxml 329.3 ms. The fastest regex also found only 3,000 of the 6,000 links.

Which regex finds all links in HTML?

None that we tried was right on every input. Our hardened regex removes comments and scripts, ignores case, accepts every quote style and decodes entities. It was right on 12 of 13 inputs and missed a link whose tag had a > inside a quoted value.

What should I use instead of regex to parse HTML?

An HTML parser. In Python, BeautifulSoup and lxml were both right on all 13 of our inputs. lxml on its own was about 9 times faster than BeautifulSoup on the same page.

When is it fine to use regex on HTML?

On text a parser has already pulled out of the page, such as a price, a date or an order number. A quick search in HTML you wrote yourself is fine too, as long as you check what it missed.

Get proxies that are alive right now

Our free proxy list re-checks every exit every few minutes across 100+ countries, with a live last-checked time, so you copy IPs that worked moments ago, not a stale text dump. When the location has to survive a real check, the paid network holds up.

129M+ proxy checks run · 100+ countries · HTTP / HTTPS / SOCKS · re-checked every few minutes · no signup

HProxy.

Honest guides and comparisons on proxies, scraping and staying unblocked, from the team that runs the network.

RSS feed