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, whose RegExp follows 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 skip 12345; 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)
Diagram with one input, abc1, branching into three modes. Greedy: \w+ takes abc1, gives back 1, and the match is abc1. Lazy: \w+? takes a, then ab, then abc, and \d matches 1, so the match is abc1. Possessive: \w++ takes abc1 and keeps it, nothing is left for \d, so there is no match.
The same input, abc1, under three quantifier modes. Greedy \w+ takes everything and gives back the 1 so \d can match. Lazy \w+? takes one character and grows until \d fits. Possessive \w++ (or the atomic group (?>\w+)) keeps the 1, so the match fails.

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.

Bar chart of one search time at n = 22: .NET 10 default engine 837 ms, .NET 10 Compiled 272 ms, Python 3.12 266 ms, Dart 3.11 283 ms, PHP 8.5 2.1 ms before failing with a backtrack-limit error, and the atomic pattern (?>a+)+b about 0 ms.
Time for one search of (a+)+b in "a" × 22 + "!b" (Release builds, Apple Silicon Mac). The backtracking engines took 266 to 837 ms. PHP gave up after 2.1 ms with "Backtrack limit exhausted" and returned false. The atomic form (?>a+)+b took under 0.01 ms in every engine that supports it.

The fixes, all verified to still match aaab as aaab:

  • (?>a+)+b (.NET, Python 3.12, PHP) and a++b (Python 3.12, PHP): 0.00 ms at every n. Once a+ 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.NonBacktracking with 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.

  • #regex
  • #quantifier
  • #backtracking
  • #python
  • #dotnet
  • #php
  • #dart

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑