Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Text & Patterns  ›  Lesson

Catastrophic Backtracking & When Not to Use Regex

Regex Performance 24 minWhy some patterns take exponential time, and what to use instead
You're building a piece ofQuarry — the extractor
This piece — safe_match(): Refuses operator patterns and inputs that could take exponential time.
Scenario Operations can now type their own search patterns into Quarry. One of them pasted a pattern from a forum that has frozen the tab twice this week.
Your task
Write safe_match(pattern, text, max_len) returning "too-long" when text is longer than max_len, "risky-pattern" when the pattern has a repeated group in which every element is optional or repeated, and otherwise "match" or "no-match" from re.search. Example: safe_match(r"(a+)+b", "aaaa", 100) → "risky-pattern".

Catastrophic Backtracking & When Not to Use Regex

In short: when a match fails, the engine doesn't give up — it backtracks, giving characters back to an earlier quantifier and trying a different split of the text. Most patterns have only a few ways to split their text, so backtracking is instant. A repeated group whose body can match the same text more than one way — (a+)+, (\w+\s?)* — has exponentially many ways, and the engine tries every one before admitting defeat: each extra character can double the time. Python's re has no timeout, so the fix is to rewrite the pattern with a mandatory separator between repetitions, bound the input length, or reach for a hand-written scanner instead of a regex at all.

(a+)+b failing on "aaaac": three of the eight ways to split the a's shown, each one reaching c instead of b — carvings(4) = 8 = 2³, doubling with every extra a

Quarry has a feature request from operations: let us type our own search patterns. A reasonable ask — and it hides the most famous performance bug in text processing. This lesson measures that blow-up on your own machine, explains exactly where it comes from, and builds safe_match(): the guard every operator-supplied pattern passes through before Quarry lets it near a file.

Let operators search the yard with their own pattern

Here's a pattern that looks harmless: (a+)+b — "one or more runs of a's, then a b". Against a string of a's that ends in c instead of b, it has to fail. Time how long failing takes:

python

Read your own numbers down the right column: every two extra characters roughly quadruple the time — the signature of 2ⁿ. Now run the same experiment with the pattern rewritten as a+b, which accepts exactly the same strings:

python

Flat. Same language, same answer, completely different cost. Whatever's going on is about the shape of the pattern, not what it matches.

Remember the engine's rule: when something fails, it backtracks and tries another way to consume the same text. (a+)+ has an enormous number of other ways. For aaaa, the outer + can use one inner run of four, or 3+1, 1+3, 2+2, 2+1+1, 1+2+1, 1+1+2, or 1+1+1+1 — eight carvings, each one reaching the end, finding c where it wanted b, and failing. The engine won't give up until it has tried them all.

step through it
1def carvings(n):
2 if n == 0:
3 return 1
4 total = 0
5 for first in range(1, n + 1):
6 total += carvings(n - first)
7 return total
8 
9print(carvings(4))
What does this print?
def carvings(n):
    if n == 0:
        return 1
    return sum(carvings(n - first) for first in range(1, n + 1))

print(carvings(5), carvings(10))

At a generous million attempts per second, (a+)+b failing on n characters takes about 2ⁿ microseconds:

characters time to fail
20 about 1 second
26 over a minute
35 about 9.5 hours

A log line is often longer than that. The rewritten a+b fails in a few dozen steps, well under a millisecond.

Catch the risky shape before it ever runs

Catastrophic backtracking needs two ingredients: a repeated group — (…)+, (…)* or (…){n,} — whose body can match the same text in more than one way from one repetition to the next. (\w+\s?)* is a common attempt at "words separated by spaces" that qualifies, because \s? is optional, so abc can be one word or three. Compare \d+(?:,\d+)*: every repetition must begin with a mandatory comma, so 12,345,6 only splits one way. Safe.

That gives a rule Quarry can check mechanically: inside a repeated group, if every element is itself optional or repeated, the group is dangerous. A mandatory literal separator breaks the ambiguity. It's a heuristic — it misses overlapping alternatives like (a|aa)+ — but it catches the shapes behind nearly every real incident.

python

GROUP finds a parenthesised body with no parentheses inside it, followed by a repeating quantifier, skipping over a ?: or ?P<name> prefix. ATOM.findall turns the body into (atom, quantifier) pairs; if every pair has a quantifier, nothing in a repetition is mandatory, and the group is flagged.

What does this print?
import re
ATOM = re.compile(r"(\\.|\[[^\]]*\]|[^|])([+*?]|\{\d*,?\d*\})?")
print(ATOM.findall(r",\d+"))

Three more defences belong beside the rewrite: bound the input length (exponential cost is harmless on tiny inputs); since Python 3.11, possessive quantifiers and atomic groups (a++, (?>…)) never give back what they took, so (?>a+)+b fails instantly; and, sometimes, don't use a regex at all.

When a scanner beats a regex

A regex is the right tool for a flat shape. It's the wrong one when a plain string method will do ("ERROR" in line can't backtrack), when the structure nests (balanced brackets, JSON — re can't count nesting depth), when the rules are about meaning (valid dates, checksums), or when the pattern has become unreadable.

A hand-written scanner — a loop with an index that looks at one character, decides what kind of token starts there, and consumes it — is longer than a regex but runs in plainly linear time, nothing to backtrack into. This one splits a tail into words, but has a bug:

broken — fix it

The last word goes missing. Run it, find the line responsible, and fix it without changing how the loop scans.

The next lesson builds a real scanner for Quarry's log tails.

Quick practice

What does this print?
import re
print(bool(re.fullmatch(r"\d+(?:,\d+)*", "12,345,6")))
What does this print?
import re
GROUP = re.compile(r"\((?:\?:|\?P<\w+>)?([^()]*)\)(?:[+*]|\{\d+,\d*\})")
ATOM = re.compile(r"(\\.|\[[^\]]*\]|[^|])([+*?]|\{\d*,?\d*\})?")

def is_risky(pattern):
    for group in GROUP.finditer(pattern):
        for branch in group.group(1).split("|"):
            atoms = ATOM.findall(branch)
            if atoms and all(q for _, q in atoms):
                return True
    return False

print(is_risky(r"(a|aa)+"))

🎯 Your turn

Write safe_match(pattern, text, max_len), the guard every operator-supplied pattern goes through. Check in this order and return one of four strings:

  1. "too-long" if text is longer than max_len,
  2. "risky-pattern" if the pattern has a repeated group in which every element is optional or repeated (use the GROUP/ATOM check from this lesson),
  3. otherwise "match" if re.search(pattern, text) finds something, else "no-match".
  • safe_match(r"\d+ms", "took=312ms", 100) → "match"
  • safe_match(r"(a+)+b", "aaaa", 100) → "risky-pattern"
  • safe_match(r"ERROR", "all good", 100) → "no-match"

Hint — the two cheap checks come first so the expensive one only ever runs on bounded input with a pattern that passed inspection.

Then press ▶ Run and hit ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upCount the carvings

Write carvings(n) returning the number of ways to cut a run of n identical characters into one or more non-empty consecutive pieces — the number of attempts (a+)+ makes on it. By convention carvings(0) is 1.

catastrophic-backtrackingrecursion
DrillSplit a group body into atoms

Write atoms(body) returning the atoms of a regex group body as a list of [atom, quantifier] pairs (lists, not tuples), using the lesson's ATOM pattern. The quantifier is "" when an atom has none.

tokenisingre.findall
BuildFlag the dangerous shape

Write is_risky(pattern): True when the pattern contains a repeated group (+, * or {n,} after it) with a branch in which every atom is quantified. Use the lesson's GROUP and ATOM patterns, which are provided.

regexcatastrophic-backtrackingtokenising
BossA hand-written scanner

Write tokenise(tail) without re, returning a list of [kind, text] tokens. Skip whitespace. = is ["EQ", "="]. A double quote starts a STRING running to the next double quote (or the end); its text excludes the quotes. A run of letters, digits and any of _.-:/@ is a NUMBER if it is digits with at most one decimal point and digits after it, otherwise a WORD. Any other character is ["OTHER", ch].

tokenisingscannerloops
CapstoneTyped fields from a tail

Write scan_fields(tail) returning a dict of every WORD = value sequence in the tail, using a scanner like the boss exercise's. Values are typed: a NUMBER becomes an int (or float if it has a decimal point); a WORD that is a duration — digits, optional decimals, then ms or s — is stored under key + "_ms" as whole milliseconds; a STRING or any other WORD stays a string. Tokens that are not part of a WORD = value triple are ignored.

tokenisingscannerregex
ExplainWhy a nested quantifier explodes

Explain to a teammate why (\w+\s?)*$ can take minutes to fail on a 30-character line while \w+(?:\s\w+)*$ fails instantly, even though they are meant to accept the same kind of text. Mention what the engine is doing during those minutes.

safe_match.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
safe_match(pattern, text, max_len) → strtoo-long, risky-pattern, match or no-match.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.