Regex Explained: How to Read Any Regular Expression

A regular expression is a tiny program that describes a shape in text, and it is read left to right as position, characters, and quantity. Learn that, plus the two things that separate people who use regex from people who fear it: greedy versus lazy matching, and the nested quantifier that once cost Cloudflare 27 minutes of traffic.

Tech Talk News Editorial9 min read
ShareXLinkedInRedditEmail
Regex Explained: How to Read Any Regular Expression

Key takeaways

  • A regular expression is read left to right as three repeating questions at each position: where am I anchored, which characters are allowed here, and how many of them do I take.
  • Greedy quantifiers match as much as possible, so the pattern <.*> against the text "<a> b <c>" matches the entire string, while the lazy version <.*?> matches only "<a>".
  • Nested quantifiers such as ^(a+)+$ force a backtracking engine to try exponentially many ways to split the same text. OWASP counts 65,536 paths for a 16-character non-matching input, and the count doubles with every extra character.
  • Measured on Python 3.14.4, matching ^(a+)+$ against 24 letters followed by an X took 1.3 seconds, 28 letters took 9.5 seconds, and 30 letters took 40.5 seconds. Node.js took 44 seconds on the same 30-letter input.
  • A backtracking regex in a firewall rule took roughly 80% of Cloudflare's traffic offline for 27 minutes on July 2, 2019, and Google's RE2 and the Rust regex crate avoid that entire class of failure by guaranteeing linear match time and refusing backreferences and look-around.

A regular expression is a tiny program. Not a magic string, not a password you copy off Stack Overflow and hope for. It is a program whose only job is to describe a shape in text, and whose only output is: does this shape occur here, and if so, where does it start and stop. Every language ships one. Python calls it re, JavaScript bakes it into the syntax with slashes, Go calls it regexp. The dialects differ at the edges and agree in the middle, so reading skill transfers.

I think the fear comes from one bad habit: people read a pattern as a single blob and try to recognize it, the way you recognize a word. That never works. A regex is read the way the engine reads it, one piece at a time, left to right. Do that and ^\d{4}-\d{2}-\d{2}$ stops being noise and starts being a sentence.

Read it left to right, asking three questions

At every step through a pattern, the engine is answering some combination of three questions. Where am I allowed to be? Which characters are allowed here? How many of them do I take? That is genuinely most of it.

The mental model

A pattern is read left to right, one position at a time

What each piece answers

  • Anchors: ^ $ \bWhere am I allowed to be? Start of string, end of string, edge of a word.
  • Literals and classesWhich characters are allowed here? "a" matches an a; \d matches a digit.
  • Quantifiers: * + ? {n}How many of the thing to my left do I take?
  • Groups and alternationWhich part do I keep, and which of these options do I try?

The engine walks the pattern and the text together

It holds one position in the text and one position in the pattern, and advances both until either the pattern runs out (a match) or a piece fails.

What comes back

  • A match, with a spanThe start and end offsets in the original string.
  • Capture groupsThe substrings you asked to keep, numbered or named.
  • No matchNothing found, which is a normal answer and not an error.
Refused on purpose by RE2 and RustBackreferences and look-aroundThe only constructs with no known non-backtracking implementation, so the linear-time engines drop them rather than risk exponential match time.

Reading order for any regular expression, and the two features the safe engines leave out.

Takeaway

You never have to understand a whole pattern at once. Chunk it into position, characters, and quantity, and read each chunk in order.

Start with the smallest possible pattern. A literal matches itself, which is exactly your editor's find box.[1]

literals_and_boundaries.pyPython
import re

text = "the cat scattered the cathedral"

# A literal matches itself, anywhere it occurs.
re.findall(r"cat", text)
# ['cat', 'cat', 'cat']   <- also inside "scattered" and "cathedral"

# \b is a word boundary: the seam between a word character and a non-word one.
re.findall(r"\bcat\b", text)
# ['cat']
Verified on Python 3.14.4. The r prefix is a raw string, which stops Python eating the backslash before the regex engine sees it.

That second pattern is the whole idea in miniature. \b matches the empty string, but only at the boundary between a word character and a non-word character.[1] It consumes nothing. It just refuses to be anywhere else. Anchors are constraints on position, not on content, and once that clicks a lot of patterns get easier.

Build one up, a piece at a time

Character classes come next. A class in square brackets means “any one character from this set”, and the common ones get shorthands: \d for a digit, \w for a word character (letters, digits, underscore), \s for whitespace.[1] Then quantifiers say how many. Then anchors pin the whole thing down.

building_up.pyPython
import re

# Character class + fixed count.
re.findall(r"[0-9]{3}-[0-9]{4}", "call 555-0142 or 555-9981")
# ['555-0142', '555-9981']

# Same idea with the \d shorthand, anchored to the whole string.
re.fullmatch(r"\d{4}-\d{2}-\d{2}", "2026-08-16")
# <re.Match object; span=(0, 10), match='2026-08-16'>

# Alternation: try the left side, then the right.
re.findall(r"ERROR|FATAL", "INFO ok\nERROR disk\nFATAL bus")
# ['ERROR', 'FATAL']
Every output above was produced by running the snippet, not transcribed from memory.

Summary

The quantifier set is short and worth memorizing: * is zero or more, + is one or more, ? is zero or one, {n} is exactly n, and {n,m} is between n and m. They all apply to the single thing immediately to their left, which is a character, a class, or a group.

Notice the date pattern says nothing about whether the date is real. It matches 9999-99-99 happily. That is not a flaw, it is the deal: a regex checks shape, and your code checks meaning. Every time I have seen someone try to validate a date range inside the pattern, the pattern got twice as long and still needed the check afterward.

Groups are the part you actually use

Parentheses do two jobs at once. They group things so a quantifier can apply to the whole run, and they capture, meaning the engine hands that substring back to you. Numbered groups start at 1 and count opening parentheses left to right.[1] Named groups do the same thing without the counting.

captures.pyPython
import re

m = re.search(r"(\d{4})-(\d{2})-(\d{2})", "released 2026-08-16 ok")
m.groups()      # ('2026', '08', '16')
m.group(1)      # '2026'

# Named captures: same pattern, readable at the call site.
m = re.search(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})",
              "released 2026-08-16 ok")
m.group("year")  # '2026'
m.groupdict()    # {'year': '2026', 'month': '08', 'day': '16'}
The (?P<name>...) spelling is Python's. JavaScript, Java and .NET use (?<name>...) for the same feature.

Use named groups. Numbered groups are fine when you write them and hostile six months later when someone inserts a parenthesis in the middle and every index after it shifts by one. The naming syntax is one of the small places where Python went its own way, which is the sort of difference worth knowing if you move between languages, as in this side-by-side comparison of Python and Java syntax.

A regex checks shape. Your code checks meaning. Almost every unreadable pattern I have met is one that was asked to do both.

Greedy versus lazy, the first real fork in the road

Here is where people start losing arguments with their own patterns. Quantifiers are greedy by default: they match as much text as possible, then give characters back one at a time only when the rest of the pattern fails. The Python docs use the perfect example.[1]

greedy_vs_lazy.pyPython
import re

text = "<a> b <c>"

re.findall(r"<.*>", text)     # greedy: takes everything it can
# ['<a> b <c>']

re.findall(r"<.*?>", text)    # lazy: takes as little as it can
# ['<a>', '<c>']

re.findall(r"<[^>]*>", text)  # better: no choice to make at all
# ['<a>', '<c>']
The third pattern is usually the right answer in real code.

Read the greedy case the way the engine does. .* runs to the end of the string. Then the engine needs a >, has nothing left, and walks backward until it finds one. The last > in the string is the closing bracket of <c>, so the match spans the whole line. Adding a question mark reverses the direction: start at zero characters and grow only when forced.

Takeaway

Greedy overshoots and reels back. Lazy undershoots and creeps forward. A negated character class like [^>]* does neither, because it can never cross the delimiter in the first place, which is why it is both faster and clearer.

The failure mode with a body count

Now the thing that actually matters, and the reason serious codebases have opinions about regex. Most engines you use daily, including Python, JavaScript, Java, .NET and PCRE, are backtracking engines. They try one alternative at a time and rewind when it fails. That is what makes backreferences and look-around possible. It is also what makes them detonate.

The classic trigger is a quantifier inside a quantifier. OWASP lists (a+)+$ first among its evil regex examples.[4] Ask why and the answer is combinatorics: for the input aaaaX, OWASP counts 16 distinct paths through the two quantifiers, every one of which fails on the X. That is 2n, not the eight ways of splitting the run itself, because the outer + re-enters for each split. Push it to sixteen letters and the count is 65,536, and it doubles for each additional character.[4]

I ran it, because a number you have not seen with your own eyes is just a rumor.

1.3 s
Python, ^(a+)+$ against 24 letters and an X
9.5 s
7x slower for 4 more characters
The same pattern at 28 letters
40.5 s
4x slower for 2 more
The same pattern at 30 letters
0.0001 s
No nesting, no explosion
The equivalent ^a+$ against 10,000 letters

Takeaway

Every extra character multiplies the work rather than adding to it, which is the textbook signature of exponential time complexity. A 31-character string is not big data. It fits in a tweet, an HTTP header, or a username field.

Node.js gave me 44 seconds on that same 31-character input, so this is not a Python problem. And notice the last stat: ^a+$ matches exactly the same set of strings as ^(a+)+$, in microseconds, on an input 300 times longer. The nesting bought nothing. It never does. That is the tell.

Heads up

When the string being matched comes from a user, this stops being a performance bug and becomes a denial-of-service vulnerability, named ReDoS. Python 3.11 added possessive quantifiers (*+, ++) and atomic groups ((?>...)) that throw away backtracking positions and shut the blowup down.[1] Useful, but a rewrite of the pattern is usually better than a patch on it.

If that still feels academic, it has a receipt. On July 2, 2019, Cloudflare deployed a new firewall rule containing the subexpression .*.*=.*. Two .* in a row is a milder relative of the same disease: two greedy runs competing for the same characters, quadratic rather than exponential. Quadratic is enough. Their own step count fits n2 + 7n + 15 exactly. Their own postmortem walks through it, and notes that matching x= followed by twenty x characters takes 555 steps.[5]

July 2, 2019, all times UTC

27 minutes of a global network, ended by one subexpression

  1. 13:42

    The rule ships

    A new managed firewall rule is deployed globally to catch cross-site scripting. It contains .*.*=.* .

  2. 13:45CPU near 100%

    Pagers fire

    The CPUs serving HTTP and HTTPS traffic saturate across the network. Cloudflare reports losing roughly 80% of its traffic.

  3. 14:00

    The firewall is identified as the cause

    Not a routing problem, not an attack. The regex engine is eating every core it can reach.

  4. 14:07

    Global firewall kill switch pulled

    The team disables the WAF everywhere rather than trying to ship a fix under load.

  5. 14:0927 minutes

    Traffic and CPU return to normal

    The full firewall is re-enabled at 14:52 once the offending rule is removed.

Takeaway

A pattern that passed code review, in a product built by people who are very good at this, took a chunk of the internet down for 27 minutes. Nobody is too smart for catastrophic backtracking. The defense is the engine, not the reviewer.

Timeline reconstructed from Cloudflare's own incident postmortem.

Go and Rust just refuse

Here is the part I find genuinely elegant. Google's RE2, which is the engine behind Go's regexp package, has been in production since 2006 with an explicit goal of handling patterns from untrusted users. Its README states the guarantee plainly: match time is linear in the length of the input string.[2] The Rust regex crate makes the same promise in complexity notation, worst case O(m * n) where m is the size of the pattern and n the size of the text.[3]

Both pay for it the same way. Instead of trying alternatives one at a time and rewinding, they track every possible position at once, which means the work per input character is bounded no matter how the pattern is shaped. And both drop the features that make that impossible. RE2 puts it as a matter of principle: it does not support constructs for which only backtracking solutions are known to exist, so backreferences and look-around assertions are not supported.[2] Rust says the same thing in its opening paragraph.[3]

In a sense, RE2 is pessimistic where a backtracking engine is optimistic. This pessimism is what makes RE2 secure.
RE2 README, google/re2

That is a genuine tradeoff, not a free lunch. If you need to match repeated words with (\w+) \1, RE2 cannot help you, and people hit that wall and get annoyed. My honest read after years of this: the wall is a feature. If your pattern needs a backreference, you have probably wandered out of the territory regular expressions are good at, and the language forcing you to notice is doing you a favor.

Where to stop

Two places deserve a hard no, and both are famous for a reason.

Parsing HTML. HTML nests, and a regular expression has no memory of how deep it currently is, so it cannot match an opening tag to its own closing tag across arbitrary nesting. Use a parser for structure. A regex is still the right tool for finding an ISO date inside the text a parser hands you.

Validating email addresses to the RFC. RFC 5322 permits comments, folded whitespace and quoted strings inside an address, so a fully conformant pattern is thousands of characters long and matches things no mail provider will accept. The WHATWG HTML Standard, which defines what a browser does for <input type="email">, declined the whole problem. Its spec text calls its own simpler definition a “willful violation of RFC 5322”, on the grounds that the real grammar is simultaneously too strict before the at sign, too vague after it, and too lax overall to be of practical use.[6] When the HTML specification says the standard is unusable and ships a shorter pattern instead, take the hint and copy theirs.

Side note

The single best debugging habit here costs nothing: write the pattern in verbose mode, one piece per line, with a comment on each. Python spells the flag (?x) and Rust supports it too. It turns an unreadable string into something that looks like code, and the comment marker is the same hash symbol you already use. Your editor will highlight it properly too.

So: read left to right, name your groups, prefer a negated class over a lazy quantifier, and treat a quantifier inside a quantifier as a bug until proven otherwise. If the text you are matching comes from strangers, run it on an engine that cannot explode. Regex is not scary. It is just a language nobody bothered to teach you the grammar of, and the grammar takes about a page.

Primary sources

  1. 1.PrimaryPython documentation, "re: Regular expression operations". Greedy and non-greedy quantifiers and the <.*> example, the (?P<name>...) named-group syntax, backreferences, \b word boundaries, the \d \w \s classes, and the possessive quantifiers and atomic groups added in Python 3.11.
  2. 2.PrimaryRE2 README, google/re2. In production at Google since 2006. "One of its primary guarantees is that the match time is linear in the length of the input string." Backreferences and look-around assertions are not supported as a matter of principle.
  3. 3.PrimaryRust regex crate, rust-lang/regex. Lacks look-around and backreferences; in exchange, all searches have worst case O(m * n) time complexity where m is the size of the regex and n the size of the string.
  4. 4.PrimaryOWASP, "Regular expression Denial of Service (ReDoS)". The evil regex list including (a+)+$, the 16 paths for the input aaaaX, and 65,536 paths at sixteen characters with the count doubling per additional character.
  5. 5.PrimaryCloudflare, "Details of the Cloudflare outage on July 2, 2019". The 13:42 UTC deploy, CPU saturation across the network, roughly 80% of traffic lost, the global WAF kill at 14:07, recovery at 14:09, and the 555-step walkthrough of .*.*=.* .
  6. 6.PrimaryWHATWG HTML Standard, "Valid e-mail address". The willful violation of RFC 5322 and the reasoning that the RFC grammar is too strict before the at sign, too vague after it, and too lax to be of practical use.

Frequently asked questions

What is a regular expression?
A regular expression is a compact pattern language for describing the shape of text, which an engine then matches against a string. A pattern is built from literals (characters that match themselves), character classes such as \d for a digit, quantifiers such as + for one or more, anchors such as ^ and $ for the start and end of the string, groups in parentheses, and alternation with the pipe symbol. Almost every language ships one, and the syntax is close enough between them that reading skills transfer.
What is the difference between greedy and lazy quantifiers in regex?
A greedy quantifier takes as many characters as it can and then gives them back only when the rest of the pattern fails, while a lazy quantifier takes as few as possible and grows only when forced. The Python documentation gives the canonical example: matched against the text "<a> b <c>", the greedy pattern <.*> matches the whole string, and adding a question mark to make it <.*?> matches only "<a>". A negated character class such as <[^>]*> is usually a better fix than either, because it removes the choice instead of tuning it.
What is catastrophic backtracking and how does it cause ReDoS?
Catastrophic backtracking is what happens when a pattern gives a backtracking engine exponentially many ways to match the same text, so a short input can take seconds or hours to reject. The classic trigger is a quantifier nested inside another quantifier, such as ^(a+)+$, where every extra character doubles the number of paths the engine may explore. OWASP counts 65,536 paths for a 16-character non-matching input. When the input comes from a user, that is a denial-of-service vulnerability, known as ReDoS.
Why should you not parse HTML with a regular expression?
HTML is nested and recursive, and a regular expression has no memory of how deep it currently is, so it cannot reliably match an opening tag to its own closing tag. Regular expressions describe flat shapes, which is why they are excellent at finding an ISO date or a log level inside HTML and terrible at extracting the third cell of a table that contains another table. Use a real parser for the structure and a regex for the text inside it.
Which regex engines are immune to ReDoS?
Engines built on the automaton approach rather than backtracking are immune, and the two most widely used are Google's RE2 (the engine behind Go's regexp package) and the Rust regex crate. RE2's README states that its primary guarantee is that match time is linear in the length of the input string, and the Rust crate guarantees worst-case O(m * n) time. Both buy that guarantee by refusing backreferences and look-around assertions, which are the features with no known non-backtracking implementation.

Written by

Tech Talk News Editorial

Computer engineering background. Writes about software, AI, markets, and real estate, and the places where the three meet.

More about the author
ShareXLinkedInRedditEmail