Regex Quantifiers: Greedy, Lazy, Possessive, Measured
Regex quantifiers tested in .NET, Python, PHP and Dart: {n,m} pitfalls, greedy vs lazy vs possessive, which engines accept a++, and (a+)+b timed.
Revised November 2026. This tutorial first appeared in Korean in 2021. We re-ran its five examples with identical inputs in .NET 10 (System.Text.RegularExpressions), Python 3.9 and 3.12
re, PHP 8.5 (PCRE2 10.47) and Dart 3.11, whoseRegExpfollows JavaScript rules. The matches agreed in every engine. Two explanations did not:{3}does not skip a four-digit number, and{2,4}does not skip12345; both find a match inside the longer number unless you add anchors. Possessive quantifiers, atomic groups and a backtracking measurement are new.
This article is for anyone who has written \d{3} and been surprised by what it accepted, or who has had a regex freeze a request handler. A quantifier says how many times the item before it may repeat. Two things decide the result: the count ({n}, {n,m}, *, +, ?), and the mode (greedy, lazy or possessive), which decides how hard the engine tries and whether it gives characters back. The count is portable. The mode is not, and the wrong combination can make one regex call take seconds.
If you are not sure what *, + and ? mean as metacharacters, read our metacharacter article first. Matching runs of the same character is a different technique, covered in finding repeated characters.
Counting: {n}, {n,} and {n,m}, re-checked
The three counted forms:
| Quantifier | Meaning |
|---|---|
{n} |
exactly n times |
{n,} |
n or more times |
{n,m} |
between n and m times |
* is {0,}, + is {1,} and ? is {0,1}. Here are the 2021 examples with what every engine returned:
Pattern: [0-9]{3} Text: 12 / 123 / 1234 / 45678 (one per line)
Result: 123, 123, 456
Pattern: \d{3,}\.\d+ Text: 123.456 / 12.3456 / 1.23456 / 1234.56 / 12345.6
Result: 123.456, 1234.56, 12345.6
Pattern: \d{2,4} Text: 1 / 12 / 123 / 1234 / 12345
Result: 12, 123, 1234, 1234
The 2021 text said [0-9]{3} would not match a four-digit number and that {2,4} would not match 12345. The results show otherwise: the engine looks for a place where the pattern fits, and 123 fits inside 1234. A quantifier limits how many characters the item consumes, not what surrounds it. To make the count mean "the whole number", add boundaries:
\b[0-9]{3}\b → 123 only
^\d{2,4}$ (multiline) → 12, 123, 1234
A quantifier applies only to the single item right before it: ab+ repeats b, so on abab abbb it returned ab, ab, abbb. To repeat a longer piece, group it, as in (ab)+, which returned abab and ab. Groups are covered in subexpressions.
Two edge cases differed or failed in instructive ways. A reversed count is an error everywhere:
.NET : Invalid pattern 'a{3,1}' at offset 6. Illegal {x,y} with x > y.
Python : min repeat greater than max repeat at position 2
PHP : Compilation failed: numbers out of order in {} quantifier at offset 5
Dart : FormatException: numbers out of order in {} quantifier.
The shorthand {,3} is not portable. On aaaa a{,3}, Python (3.9 and 3.12) and PHP read it as {0,3} and returned aaa, a and a run of empty matches. .NET and Dart read it as literal text and matched the characters a{,3}. Dart with unicode: true rejected it. Write {0,3}.
Greedy and lazy: the same count, different choice
A plain quantifier is greedy: it takes as much as it can and gives characters back only if the rest of the pattern fails. Adding ? makes it lazy: it takes as little as it can and extends only when forced. The 2021 example still shows it best:
Text: <b>BOLD!</b><hr><b>BOLD!</b>
<b>.*</b> → one match: the whole line
<b>.*?</b> → two matches: <b>BOLD!</b> and <b>BOLD!</b>
<b>[^<]*</b> → two matches, same as lazy
All engines agreed. The third line is often the better fix: a negated set states what may appear inside, so there is nothing to backtrack over. Lazy quantifiers can surprise you too. \d{2,4}? on 12345 returned 12 and 34, and \d??\d on 12 returned 1 and 2. Laziness only changes which valid length is tried first; it never makes a match fail that would otherwise succeed.
| Greedy | Lazy | Possessive |
|---|---|---|
* |
*? |
*+ |
+ |
+? |
++ |
? |
?? |
?+ |
{n,m} |
{n,m}? |
{n,m}+ |
Possessive quantifiers and atomic groups
A possessive quantifier is greedy and never gives anything back. That changes results, not just speed:
Text: abc1
\w+\d → abc1 (\w+ takes abc1, gives back 1, \d takes it)
\w++\d → no match (\w++ keeps the 1)
(?>\w+)\d → no match (atomic group: same behaviour)
[a-z]++\d → abc1 (the set cannot take 1, so nothing needs giving back)
Support is where engines split, and the errors are worth recognising:
| Pattern | .NET 10 | Python 3.9 | Python 3.12 | PHP 8.5 | Dart 3.11 |
|---|---|---|---|---|---|
\w++\d on abc1 |
error | error | no match | no match | error |
(?>\w+)\d on abc1 |
no match | error | no match | no match | error |
"[^"]*+" on say "hi" now |
error | error | "hi" |
"hi" |
error |
.NET 10 : Invalid pattern '\w++\d' at offset 4. Nested quantifier '+'.
Python 3.9 : multiple repeat at position 3
Python 3.9 : unknown extension ?> at position 1
Dart 3.11 : FormatException: Nothing to repeat \w++\d
Dart 3.11 : FormatException: Invalid group (?>\w+)\d
Python documents both features as new in 3.11; we confirmed 3.9 rejects them and 3.12 accepts them. In .NET, write the atomic group (?>…) instead of ++: (?>\w+) means exactly what \w++ means. JavaScript, and therefore Dart and Flutter, has neither, so there you fix the pattern's structure instead.
Catastrophic backtracking, measured: (a+)+b
Possessive and atomic forms matter most when a pattern fails. In (a+)+b, a run of a characters can be split between the inner and outer + in exponentially many ways, and a backtracking engine tries all of them before concluding there is no b in the right place. We searched "a" × n + "!b"; the ! guarantees failure while still putting a b in the input. Each figure is one call, Release builds, on an Apple Silicon Mac:
| n | .NET 10 default | .NET 10 Compiled | Python 3.12 | Python 3.9 | Dart 3.11 (AOT) |
|---|---|---|---|---|---|
| 16 | 13 ms | 4.2 ms | 4.6 ms | 4.4 ms | 4.5 ms |
| 20 | 209 ms | 70 ms | 72 ms | 71 ms | 72 ms |
| 22 | 837 ms | 272 ms | 266 ms | 275 ms | 283 ms |
| 24 | 3,963 ms | 1,067 ms | 1,083 ms | 1,088 ms | 1,165 ms |
| 26 | timeout (5 s) | 4,437 ms | 4,626 ms | 4,422 ms | 4,608 ms |
A second run gave 773 ms and 3,047 ms for n = 22 and 24 with the .NET default engine; second runs of .NET Compiled and Dart stayed within 4% of the table. Every two extra characters cost about four times as much. At that rate, a few dozen characters is enough to stall a request.
The fixes, all verified to still match aaab as aaab:
(?>a+)+b(.NET, Python 3.12, PHP) anda++b(Python 3.12, PHP): 0.00 ms at every n. Oncea+has taken the run, there is nothing to re-split.a+b, the pattern you actually meant: 0.00 ms everywhere, including Dart.- .NET
RegexOptions.NonBacktrackingwith the original(a+)+b: 0.01 ms or less. It does not accept atomic groups, though:RegexOptions.NonBacktracking is not supported in conjunction with expressions containing: 'atomic subexpressions (?> pattern)'.
PHP behaved differently, and the difference is a trap. With JIT on (the default), preg_match('/(a+)+b/', …) returned 0 for n = 16 after 0.52 ms, but for n ≥ 20 it returned false after about 2 ms, with preg_last_error_msg() reporting Backtrack limit exhausted (pcre.backtrack_limit = 1,000,000). With JIT off, the same error came after about 12.5 ms. PHP never hangs here, but if (preg_match(...)) treats false as "no match" and moves on. And when the input contained no b at all, PCRE2 returned 0 in 0.00 ms without trying, because it checks for a required literal first; .NET did not, and took 4,509 ms at n = 24 on "a" × 24.
Choosing a quantifier in practice
| Situation | Write | Avoid |
|---|---|---|
| Exact length | ^\d{3}$ or \b\d{3}\b |
bare \d{3} |
| Up to a delimiter | "[^"]*" |
".*" |
| Shortest match between markers | <b>.*?</b> |
<b>.*</b> |
| Nested repetition | flatten: a+b |
(a+)+b |
| Must not give back | .NET (?>…); PHP and Python 3.11+ ++ |
++ in .NET or Dart |
| Untrusted input in .NET | matchTimeout or NonBacktracking |
an unbounded default Regex |
| PHP | check === false and preg_last_error() |
if (preg_match(...)) alone |
For a real-world example of the same blow-up with a backreference, see our backreferences article, where a pattern with an optional separator inside a repeated group hit a 2-second timeout at 26 characters.
How we tested
Engines: .NET 10.0.14 (Regex.Matches, Stopwatch, Release), Python 3.9.6 and 3.12.14 (re.finditer, time.perf_counter), PHP 8.5.6 with PCRE2 10.47 (preg_match_all, hrtime, JIT on and off) and Dart 3.11.5 (RegExp.allMatches, compiled with dart compile exe, Stopwatch). Node.js was not installed, so JavaScript rules were checked through Dart. Match tests read their patterns and inputs from one JSON file so every engine saw identical bytes; timings are single calls after a warm-up, so treat them as orders of magnitude, not benchmarks.
Tested on macOS on an Apple Silicon Mac, November 2026.
FAQ
Does [0-9]{3} reject a four-digit number?
No. Without anchors it matched "123" inside "1234" in every engine we tested, and \d{2,4} matched "1234" inside "12345". To require the exact length, add boundaries: \b[0-9]{3}\b or ^\d{2,4}$ in multiline mode.
Which engines support possessive quantifiers like a++?
PHP (PCRE2 10.47) and Python 3.12 accepted them. Python 3.9 failed with "multiple repeat at position 3", .NET 10 with "Nested quantifier", and Dart with "Nothing to repeat". .NET does support the equivalent atomic group (?>...), and so does Python 3.12; Dart does not.
Why did preg_match return false instead of 0?
Because PCRE hit its backtracking limit. With (a+)+b on 20 or more a characters followed by "!b", PHP 8.5 returned false and preg_last_error_msg() said "Backtrack limit exhausted" after about 2 ms. Code that only checks if (preg_match(...)) treats that as "no match", so check for false explicitly.