<.+> against <b>bold</b> and <i>italic</i> doesn’t match <b>. It matches the whole string, from the first < to the very last >. Nothing is broken. That is what a greedy quantifier is supposed to do, and it is also the most common “regex is matching too much” bug there is. Once you know what the engine does while it reads your pattern, this bug and most of its relatives stop being mysteries.

How a Regex Engine Reads Your Pattern

A regex engine does not compare your pattern against the string the way you compare two strings for equality. The engines you use day to day (Python’s re, JavaScript’s RegExp, PCRE, .NET, Java) are regex-directed: they walk the pattern left to right, token by token, and for each token try to consume characters from the string at the current position. When a token matches, the engine advances both the pattern pointer and the string pointer. When a token fails, the engine backtracks. It returns to the last point where it had a choice, such as a quantifier that could have matched fewer characters or an alternation branch it hasn’t tried, and takes the other option.

That is why <.+> swallows the whole line. .+ is greedy, so it first grabs every remaining character in the string, every < and > included. The engine still needs a literal > to finish, so it gives characters back one at a time from the end until the last > in the string satisfies it. The engine returns the first match it finds in that greedy-first order. For this pattern, that is the longest possible one, not the one that looks right to you.

Try the greedy option first, back off one step on failure: that one rule explains both normal everyday matching and the pathological slowdowns further down. It is the same explore-and-undo shape as a recursive search with no memoization, the kind of blowup recursion explained covers from the function-call side.

Core Regex Syntax: Anchors, Quantifiers, Groups, Lookarounds

Anchors and Character Classes

^ and $ anchor a match to the start and end of the string (or of a line, with the multiline flag). \d, \w, and \s are shorthand character classes for digit, word character, and whitespace, and [...] lets you spell a class out directly.

1
2
3
4
import re

re.search(r"^\d{3}-\d{4}$", "555-0199")
# <re.Match object; span=(0, 8), match='555-0199'>

Without the anchors, \d{3}-\d{4} would also match inside "call 555-0199 now". Anchors turn “contains” into “is exactly.”

Character classes operate on whatever the engine considers one character, and outside plain ASCII that is not always what you expect. \w matches a letter, digit, or underscore in the engine’s notion of a character, which is the code-point-versus-byte distinction covered in what Unicode and UTF-8 encoding actually do. Run a pattern against raw UTF-8 bytes instead of decoded text and a multi-byte character gets split across what the engine thinks are separate characters.

Greedy vs. Lazy Quantifiers

+, *, and {m,n} are greedy by default: they take as much as they can, then backtrack if that breaks the rest of the pattern. Put a ? after any of them and the quantifier turns lazy. It takes as little as possible and expands only when it has to.

1
2
3
s = "<b>bold</b> and <i>italic</i>"
re.findall(r"<.+>", s)    # ['<b>bold</b> and <i>italic</i>']
re.findall(r"<.+?>", s)   # ['<b>', '</b>', '<i>', '</i>']

One character of difference, a completely different result. When you want the shortest thing that satisfies the rest of the pattern, use the lazy form. If the thing you’re matching is markup, read the last section first.

Capturing, Non-Capturing, and Named Groups

(...) captures whatever it matches so you can pull it out afterward. (?:...) groups for structure, for example to apply a quantifier to a whole sub-pattern, without creating a capture. (?P<name>...) in Python ((?<name>...) in JavaScript, Java, and .NET) names a group so you don’t have to count parentheses to find it later.

1
2
3
m = re.match(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})", "2026-10-28")
m.groupdict()
# {'year': '2026', 'month': '10', 'day': '28'}
1
2
3
const m = "2026-10-28".match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);
m.groups;
// [Object: null prototype] { year: '2026', month: '10', day: '28' }

You read the result by name, from groupdict() in Python or .groups in JavaScript (named groups arrived there in ES2018), instead of match[1], match[2]. Once a pattern has more than two groups, the extra characters pay for themselves.

Lookahead and Lookbehind

A lookaround matches a position, not characters. It checks that something is (or isn’t) there without consuming it. (?=...) is a positive lookahead, (?!...) negative; (?<=...) and (?<!...) are the lookbehind equivalents.

1
2
"$40 bill".match(/(?<=\$)\d+/);
// [ '40', index: 1, ... ]   -- matches "40", not "$40"

Python’s re requires lookbehind to be fixed-width: (?<=\$) is fine, (?<=\$+) raises an error, because the engine needs to know exactly how far back to step before it checks. JavaScript, which added lookbehind in ES2018 along with named groups, has no such limit, so (?<=\$+) works there. In Python, the third-party regex package on PyPI lifts the restriction if you need a variable-width lookbehind.

Why Some Regexes Hang: Catastrophic Backtracking

Take the backtracking rule from the start of this article and give it a case it can’t recover from. Nest two quantifiers so the engine has many ways to split the same run of characters between them, then make the match fail at the very end. The engine doesn’t fail fast. It tries every split before giving up.

1
2
3
4
5
6
7
8
import re, time

pattern = re.compile(r"(a+)+b")
for n in (20, 24, 26, 28):
    s = "a" * n  # no trailing "b", so this never matches
    start = time.perf_counter()
    pattern.match(s)
    print(n, f"{(time.perf_counter() - start) * 1000:.1f} ms")
1
2
3
4
20 41.6 ms
24 525.8 ms
26 2083.6 ms
28 8369.3 ms

Every extra a roughly doubles the time: two more characters, four times slower. That is exponential growth, the curve Big O notation puts on the far side of O(n^2). (a+)+ can split a run of 28 as into groups in 2^27 different ways, and with no b in the string the engine tries them one after another before it can report “no match.” Follow the same curve to 40 characters and you are waiting around nine hours.

When the input comes from a user instead of your test fixtures, this is an exploitable vulnerability: ReDoS, regular expression denial of service. The usual trigger is exactly this shape, two quantifiers stacked with nothing between them to force a unique split.

Three ways to stop it, cheapest first:

  1. Remove the ambiguity. (a+)+b and a+b match the same successful strings here; the outer + buys you nothing. Most catastrophic patterns have a redundant layer like this once you look for it.

  2. Make the inner quantifier possessive, or wrap it in an atomic group. Both tell the engine “once this matches, do not backtrack into it.” That removes the exponential blowup, at the cost of occasionally rejecting a string a backtracking version would have matched. Python’s re has supported both since 3.11:

    1
    2
    3
    4
    5
    6
    7
    
    pattern = re.compile(r"(?>a+)+b")   # atomic group
    # or: re.compile(r"a++b")           # possessive quantifier, Python 3.11+
    for n in (28, 1_000, 100_000):
        s = "a" * n
        start = time.perf_counter()
        pattern.match(s)
        print(n, f"{(time.perf_counter() - start) * 1000:.3f} ms")
    
    1
    2
    3
    
    28 0.002 ms
    1000 0.001 ms
    100000 0.026 ms
    

    The same failing match on 100,000 characters takes 26 microseconds. JavaScript’s native RegExp has neither feature. The regex npm package adds atomic groups and possessive quantifiers as extended syntax if you need them without switching engines.

  3. Bound unbounded quantifiers. {1,50} instead of + caps how bad the worst case can get, even without rewriting the logic.

If the string can come from outside your codebase, such as a request body or a config value someone else wrote, treat nested quantifiers the way you’d treat an unbounded loop over user input. Test the pattern against a long run of the character that trips the ambiguity, not only against your happy-path fixtures.

When Not to Use Regex

A regex has no memory of how deep it is. That is fine for flat, fixed-shape text like a date or a log line with known fields. It is also why <.+> can’t reliably skip past one HTML tag: it has no concept of “this < opens a tag that the next matching > closes,” only “grab until the last > you can.” Nested structures such as HTML, JSON, source code, and balanced parentheses need a grammar, not a pattern. The typical failure is a regex that passes every test case you wrote, then meets a real file with a comment containing a > and silently matches the wrong span instead of erroring.

Use the parser that already exists: an HTML parser for HTML, json.loads for JSON, your language’s AST module for code. Keep regex for validating or extracting text with a flat shape, and switch to a parser the moment “balanced” or “nested” shows up in the description of what you’re matching.