Why does Python's re pattern ^(\d+)+$ hang on a crafted 30-character string?
answer
- The engine explores every possibility
- Failure is what costs, not success
- Two quantifiers over the same characters
- A quantifier that refuses to give back
- Atomic groups arrived in 3.11
basics
~20 sPython's re module uses a backtracking engine. Nesting one quantifier inside another over the same characters gives it exponentially many ways to split the input, so a 30-character string that ultimately fails to match costs about a billion steps.
solid answer
~50 s`(\d+)+` is ambiguous: for a run of n digits the outer `+` and the inner `+` can divide those digits between them in 2**(n-1) ways, and `re` is a backtracking engine that will try them all. A *successful* match stops at the first arrangement that works, so the blow-up only appears when the string cannot match — append one non-digit and the engine must exhaust every split before it can report failure. On CPython 3.14 that pattern takes about 2 seconds at 26 digits and 30 seconds at 30. The fix is to remove the ambiguity: `^\d+$` matches the same language linearly. Where a pattern legitimately needs the nesting, Python 3.11 added possessive quantifiers (`\d++`) and atomic groups (`(?>\d+)`), which forbid giving characters back. `re` has no timeout parameter, so a bad pattern cannot be cancelled once it starts.
code
python · 13 linesimport re
import time
ambiguous = re.compile(r"^(\d+)+$")
row = "1" * 26 + "x" # 26 digits then a character that cannot match
start = time.perf_counter()
print(ambiguous.match(row)) # None
print(f"{time.perf_counter() - start:.1f}s") # seconds, not microseconds
start = time.perf_counter()
print(re.compile(r"^\d+$").match(row)) # None
print(f"{time.perf_counter() - start:.5f}s") # microsecondsgo deeper
Recall that Python's re backtracks, and that a quantifier inside another quantifier over the same characters is the danger sign. Know that ^\d+$ is the safe way to say what ^(\d+)+$ was trying to say.
Explain the mechanics: the number of ways ambiguous quantifiers can split n characters, why only a failing input triggers the search, and what possessive quantifiers and atomic groups changed in 3.11.
Demonstrate the operational side — a worker that pins a core with no exception and no traceback, why liveness probes miss it, how you would confirm it with a stack dump, and the input-length bound you would add at the validator.
Own the policy: which inputs are allowed to reach a regex at all, whether user-supplied patterns are ever accepted, and when a linear-time engine in a bounded child process is worth losing backreferences and lookaround.
## What the engine is actually doing Python's `re` is a **backtracking** engine. A compiled pattern is a small program, and matching walks it, remembering choice points; when a branch fails, the engine returns to the most recent choice point and tries the next alternative. That design is what makes backreferences and lookaround possible, and it is also what makes some patterns exponential. The trigger is **ambiguity**: two or more ways for the same characters to be consumed by the same pattern. `(\d+)+` is the textbook case. Against `"111"`, the outer `+` can iterate once with the inner `\d+` taking all three digits, or twice as 1+11 or 11+1, or three times as 1+1+1. For n digits there are 2**(n-1) partitions, and each is a distinct path the engine may have to explore. Crucially, this costs nothing on a **match**. The engine stops the moment one arrangement succeeds, and the first one it tries — greedy, take everything — usually works. The blow-up needs a **failure** after a long ambiguous prefix. Append a single character the pattern cannot accept, and the engine is obliged to try every partition before it can honestly report "no match": ```python import re, time p = re.compile(r"^(\d+)+$") for n in (24, 26, 28, 30): s = "1" * n + "x" t = time.perf_counter(); p.match(s); print(n, round(time.perf_counter() - t, 2)) # 24 0.49 26 1.95 28 8.04 30 31.14 ``` Doubling every two characters is the signature. A 40-character field would run for hours. ## The shapes to recognize in review The nested quantifier `(x+)+` is only the clearest form. In real code the ambiguity usually hides: - `(a|a)*`, `(a|ab)*` — alternatives that can both match the same text. - `(\s*\w+\s*,)*` — the `\s*` on both ends of the group overlap, so whitespace can be claimed by either side. - `^(.*,)*$` over a comma-separated field, where `.` can also match the comma. - Long chains of optional groups, which is why hand-written email and URL patterns are a classic source. The rule of thumb: if two parts of the pattern can consume the same character, and one of them is inside a repetition, look harder. ## How it shows up in production Consider a CSV import for a payroll system that validates each row's amount column with a pattern of this shape. Ninety-nine percent of rows match on the first try and cost nothing. Then one row arrives with forty digits and a stray character — a typo, or a deliberate probe — and the worker pins one core and never returns. The failure mode is nastier than a crash because *nothing is raised*. The importer's `try/except Exception: log_and_continue` around each row never fires; the call simply does not come back. Liveness probes that only check the HTTP port still pass, the queue drains no further, and the operator sees a stuck worker with no traceback anywhere. A swallowed-exception habit does not even get the chance to swallow anything here — there is no exception, and that is exactly why the incident takes so long to diagnose. You cannot rescue it from outside, either. `re` accepts no timeout argument, and the match runs as a single call into C, so a watchdog thread cannot reliably interrupt it. `faulthandler.dump_traceback_later()` is the useful tool: arm it before the parse and it will dump every thread's stack if the deadline passes, and the stack will point straight into the `re` call and the pattern's line. ## Fixing it **First, remove the ambiguity.** `^\d+$` accepts exactly the same strings as `^(\d+)+$` and runs in linear time. Most catastrophic patterns in real code are like this — the nesting was never doing anything. **Second, forbid giving back.** Python 3.11 added possessive quantifiers (`*+`, `++`, `?+`, `{m,n}+`) and atomic groups (`(?>...)`). Both consume greedily and refuse to hand characters back on failure, which collapses the choice points: ```python re.compile(r"^(\d++)+$").match("1" * 30 + "x") # None, instantly re.compile(r"^(?>\d+)+$").match("1" * 30 + "x") # None, instantly ``` On 3.10 and earlier neither syntax exists, so rewriting the pattern is the only in-language fix. **Third, bound the input.** Check `len(field)` before matching. A cap that is generous for real data still turns an exponential into a small constant, and it is one line in the validator. **Fourth, never compile a pattern that came from a user.** If a feature genuinely needs client-supplied patterns, run the match in a separate process with `subprocess.run(..., timeout=...)` so a hang is bounded and recoverable, or use a third-party finite-automaton engine that guarantees linear time at the cost of backreferences and lookaround. Finally, treat it as testable. Patterns applied to untrusted input deserve a unit test that asserts a near-miss input completes inside a small time budget — that is the check that catches the next ambiguous pattern before it reaches a queue worker.
- Why does the same pattern return instantly on an input that matches?Because the engine stops at the first arrangement that succeeds, and greedy matching finds it immediately. The exponential is the cost of *proving* no arrangement works, which only happens on a failing input. That is why these bugs survive every test built from valid data and only appear when someone sends something slightly wrong.
- Can you cancel a regex match that is already running?Not reliably from inside the process. `re` takes no timeout, and the match is one long call into C, so a timer thread has nothing to interrupt. Bound it from outside instead: check the input length before matching, or run the match in a child process with `subprocess.run(..., timeout=...)`. `faulthandler.dump_traceback_later()` will at least tell you where it is stuck.
- What exactly does a possessive quantifier change about the search?A greedy quantifier consumes as much as it can but will hand characters back when a later part of the pattern fails. A possessive one consumes as much as it can and refuses to give any back, so the engine has no choice point to return to. That turns the whole subexpression into a single decision — much faster, and it can also make a pattern reject strings the greedy version accepted.
It is a customs officer who insists on trying every possible way to divide a suitcase's contents into bags before declaring the paperwork invalid; if the papers were valid, the first arrangement would have done.
saying these in an interview costs you the question
- Says re.match takes a timeout argument
- Thinks re.compile removes the backtracking cost
- Believes the blow-up requires a huge input
- Claims Python's re builds a finite automaton like grep
- Assumes try/except around the match catches the hang
- Suggests raising the recursion limit to fix it