Atomic groups and possessive quantifiers — having matched this, never give it back¶
One line: (?>a+) and a++ are one instruction in two spellings — throw away the backtracking positions inside — and they change what a pattern matches, not only how long it takes to fail.
Level: 301 · you have a pattern that is too slow, or one that quietly stopped matching after somebody made it faster
Keywords on this page: (?>…) · *+ · (?=…) · \1 — Topics: Performance
Almost every page about these two constructs sells them as a performance feature and stops there. The half that gets left out is the half that files bugs: a+a matches aa, and a++a matches nothing at all, in any subject, ever.
What the engine actually discards¶
A greedy quantifier does two things. It takes as much as it can, and — this is the part that is easy to forget — it writes down every shorter length it could have taken instead. Those saved positions are what "backtracking" comes back to.
subject a a
pattern a + a
a+ takes "aa", leaving nothing for the final a -> fail
backtrack to the position a+ wrote down
a+ takes "a", leaving "a" for the final a -> match
An atomic group runs the pattern inside it normally, and then, the moment the group has matched, deletes those saved positions. Nothing later in the pattern can ask the group to try a shorter length; if the rest of the pattern fails, the whole attempt fails and the match restarts at the next starting position.
A possessive quantifier is the same instruction with a narrower scope, and the equivalence is exact:
| Possessive | Atomic group |
|---|---|
a++ |
(?>a+) |
a*+ |
(?>a*) |
a?+ |
(?>a?) |
a{2,5}+ |
(?>a{2,5}) |
Measured, in Python¶
Python's re has had both since 3.11 — which surprises people who learned the language on 3.8 and have been writing (?>…) off ever since.
Verified output of atomic_basics_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. a++ is exactly (?>a+): greedy, and then never gives a character back
a+a on 'aa' -> 'aa'
a++a on 'aa' -> no match
(?>a+)a on 'aa' -> no match
a+b on 'aab' -> 'aab'
a++b on 'aab' -> 'aab'
(?>a+)b on 'aab' -> 'aab'
2. and they agree on every subject, not just these
(?>a+)b and a++b answer alike on 8 subjects: True
3. the 'optimisation' that silently stops matching
^[\w.]+\.com$ on 'example.com' -> 'example.com'
^[\w.]++\.com$ on 'example.com' -> no match
4. and the one that is free, because the class cannot overlap
^\d+\.\d+$ on '3.14' -> '3.14'
^\d++\.\d+$ on '3.14' -> '3.14'
5. the lookahead-and-backreference emulation, which needs neither feature
(?=(a+))\1a on 'aaa' -> no match
(?=(a+))\1b on 'aab' -> 'aab'
^(?=([\w.]+))\1\.com$ on 'example.com' -> no match
Block 2 is the equivalence claim, checked rather than asserted: (?>a+)b and a++b return the same verdict on all eight subjects, including the empty one.
Block 1 is the trap in miniature. a+a matches aa because the + hands a character back. a++a has nothing to hand back, so it fails — and it fails at every starting position, which makes it a pattern that cannot match any string whatsoever. The engine does not warn you about that. Neither does any engine below.
The optimisation that silently stops matching¶
Here is the shape that reaches production. Somebody has a hostname check:
It works. A profiler, a linter, or a blog post then suggests the possessive form, because "the + never needs to give anything back here":
It does need to give something back. [\w.] includes the dot, so the possessive run swallows .com as well, and \.com is left with an empty string and no way to reclaim what was taken. The pattern still compiles. Tests that only check rejected input still pass. Every valid hostname now fails, and the diff that caused it is two characters long.
The rule that predicts this is short: a possessive quantifier over a class that overlaps what follows it is a bug. [\w.]++ followed by \. overlaps. \d++ followed by \. does not, because \d can never match a dot — which is why \d++\.\d+ is the canonical good use and appears in every tutorial.
Where they genuinely pay¶
The other direction is worth the trouble. ^(a+)+$ against a long run of as and one ! is the classic exponential search: the nested quantifiers can split the run in exponentially many ways, and a subject that cannot match forces the engine to try all of them.
No timings appear below, because a timing is not reproducible. Each question is asked in a child process with a hard five-second cap, and the recorded answer is one of three words — the two subject sizes sit far enough either side of the cliff that every machine agrees which cell says what.
Verified output of atomic_cliff_py.py — regenerated by tools/run_examples.py, never hand-typed.
subject: n copies of 'a' and then '!', which nothing here can match --
the engine has to PROVE it, and that is the expensive direction.
pattern n=16 n=40
^(a+)+$ no match no answer in 5s
^(?>a+)+$ no match no match
^(a++)+$ no match no match
subject: n copies of 'a' and nothing else -- all three still match it,
so cutting the backtracking did not cost a single true answer.
pattern n=16 n=40
^(a+)+$ match match
^(?>a+)+$ match match
^(a++)+$ match match
Sixteen as is a few thousand steps. Forty is 2⁴⁰ — more than a day on any hardware, which is why that cell says nothing came back. One ?> or one + removes the entire search space, and the second table is the point worth insisting on: the fix did not cost a single true answer. Everything that matched before still matches.
That is the honest summary of when to reach for these: when the quantifier is genuinely deterministic — when what follows it cannot be matched by it — you get the same results and a bounded search. When it is not, you get different results.
Who has them, measured by running¶
Five engines, the same patterns, the same subjects. - in a cell means the engine refused to compile the pattern at all, so a column of dashes is what "this engine does not have this feature" looks like when you ask by running instead of by reading.
Verified output of atomic_probe_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
pattern subject python perl node ruby java
a+a aa yes yes yes yes yes
a++a aa no no - no no
(?>a+)a aa no no - no no
a++b aab yes yes - yes yes
(?>a+)b aab yes yes - yes yes
^[\w.]+\.com$ example.com yes yes yes yes yes
^[\w.]++\.com$ example.com no no - no no
(?=(a+))\1a aa no no no no no
(?=(a+))\1b aab yes yes yes yes yes
Four things to take from that table.
Perl, Ruby, Java and Python all agree, down to the hostname bug. This is a construct with genuinely uniform semantics across the engines that have it — rarer than it sounds in this library.
JavaScript has neither, at any version. Not (?>…), not ++, not *+, not ?+. The last two rows of the probe are the workaround, which JavaScript answers exactly like the four engines that have the real feature.
Python's row is a 3.11 story, and it is the reason this page exists in a library rather than in your memory of the language. The probe above runs one interpreter; six of them, on one machine, split cleanly:
Real run — macOS 26.6.2 x86-64, 2026-09-22. probe.py is three lines: compile a++, compile (?>a+), print both verdicts beside sys.version. This is a quoted transcript rather than a generated block because a generated block can only ever show the one interpreter the runner happens to be using.
$ for v in 3.8 3.9 3.10 3.12 3.13 3.14; do python$v probe.py; done
Python 3.8.7 a++ refused (?>a+) refused
Python 3.9.0 a++ refused (?>a+) refused
Python 3.10.7 a++ refused (?>a+) refused
Python 3.12.0 a++ compiles (?>a+) compiles
Python 3.13.11 a++ compiles (?>a+) compiles
Python 3.14.7 a++ compiles (?>a+) compiles
The change is therefore somewhere after 3.10 and no later than 3.12; the release that made it is 3.11, per its changelog. That attribution is the single claim on this page taken from a document rather than from a run — there is no 3.11 on this machine to ask.
The last two rows are the same emulation every engine can run, and they are the subject of the next section.
JavaScript: the trick that builds one out of two other features¶
A lookahead that captures, followed by a backreference that re-consumes what it captured. It works because a lookahead is not re-entered after it succeeds: the engine runs a+ greedily inside the assertion, records the longest run in group 1, and then never goes back to try a shorter one. \1 then puts that exact text on the consuming path. The saved positions are gone — which is the whole definition of an atomic group.
Verified output of atomic_emulation_js.js — regenerated by tools/run_examples.py, never hand-typed.
(?>a+)b SyntaxError
a++b SyntaxError
a*+b SyntaxError
a?+b SyntaxError
a+a on "aa" -> "aa"
(?=(a+))\1a on "aa" -> no match
(?=(a+))\1b on "aab" -> "aab"
^[\w.]+\.com$ on "example.com" -> "example.com"
^(?=([\w.]+))\1\.com$ on "example.com" -> no match
emulated atomic on 30 a's and a '!': false
emulated atomic on 30 a's: true
The four SyntaxError lines are all four spellings being refused. Then (?=(a+))\1a fails on "aa" exactly as a++a does everywhere else, (?=(a+))\1b still matches "aab", and — the proof that this is not a toy — the hostname pattern reproduces the bug faithfully too. An emulation that copied only the good half would not be an emulation.
The last two lines are the payoff: ^(?:(?=(a+))\1)+$ answers on thirty as and a ! immediately, where ^(a+)+$ on the same subject needs about 2³⁰ attempts.
The same trick works in every engine with lookahead and backreferences — the Python run above uses it too, and the probe's last two rows show Perl, Ruby and Java agreeing. Reach for it when you need atomic behaviour in JavaScript, or in any pattern that has to run in both JavaScript and a Perl-family engine. The costs are real but small: one extra capture group, which renumbers every group after it, and a pattern that no longer reads like what it does. Leave a comment.
Rust, where the two crates disagree — and one of them surprised this page¶
Verified output of atomic_rs_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
regex: (?>a+)b refused --
regex parse error:
(?>a+)b
^
error: unrecognized flag
regex: a++a on aa -> true
regex: a++b on aab -> true
fancy-regex: (?>a+)b on aab -> Ok(true)
fancy-regex: (?>a+)a on aa -> Ok(false)
fancy-regex: a++b on aab -> Ok(true)
fancy-regex: a++a on aa -> Ok(false)
fancy-regex: ^[\w.]++\.com$ on example.com -> Ok(false)
fancy-regex is the backtracker, and it implements both constructs properly: (?>a+)a and a++a fail, (?>a+)b and a++b match, the hostname pattern reproduces the bug. If you are porting Perl patterns to Rust, this is the crate that will agree with the original.
The regex crate is the finite automaton, and this is where a run overturned an expectation. (?>a+)b is refused — and the error is worth reading, because it is not "atomic groups are unsupported". The parser sees (? and expects inline flags, so > is reported as an unrecognised flag. The construct is not merely absent; the syntax is not reserved.
a++a compiles — and matches "aa". It is parsed as a repetition of a repetition, (a+)+, so the inner + hands a character back exactly as it always did. There is no possessive quantifier in regex; there is a pattern that looks like one and means the opposite. That is the most dangerous kind of difference — no error, no warning, a silently different answer. The automaton is not slower for it, because it never backtracks in the first place.
Go's regexp, which is the same family and the same guarantee, made the opposite call. Both spellings are rejected, by name:
Real run — Go 1.25.5 darwin/amd64, macOS 26.6.2 x86-64, 2026-09-22.
$ go run main.go
(?>a+)b refused: error parsing regexp: invalid or unsupported Perl syntax: `(?>`
a++b refused: error parsing regexp: invalid nested repetition operator: `++`
a*+b refused: error parsing regexp: invalid nested repetition operator: `*+`
If you are choosing between the two automaton engines and you paste patterns from Perl, that difference matters more than it looks.
PCRE2, so ABAP¶
ABAP's PCRE addition (7.55 and later) is the PCRE2 library in the kernel, so the answer is Perl's answer. PCRE2 is on any machine with pcre2grep:
Real run — PCRE2 10.48 (pcre2grep), macOS 26.6.2 x86-64, 2026-09-22. The subject file holds aab on line 1 and example.com on line 2.
$ pcre2grep '(?>a+)b' subject.txt
aab
$ pcre2grep 'a++b' subject.txt
aab
$ pcre2grep 'a++a' subject.txt
$ pcre2grep '^[\w.]+\.com$' subject.txt
example.com
$ pcre2grep '^[\w.]++\.com$' subject.txt
The two commands that print nothing exit with status 1: no line matched. a++a matched neither line, and the possessive hostname pattern matched neither line — the same two results as Perl, Python, Ruby and Java above.
In ABAP source the construct goes inside the pattern literal like any other, and the failure mode is the quiet one: a FIND that matches nothing sets sy-subrc = 4 and dumps nothing.
z_atomic_group.prog.abap in full — pasted here by tools/run_examples.py from the file CI runs.
REPORT z_atomic_group.
" ABAP's PCRE addition is the PCRE2 library, so both spellings are here: the
" atomic group (?>...) and the possessive quantifiers ++ *+ ?+. Source only --
" nothing in this repository can run ABAP, so there is no output beside it.
DATA(host) = `example.com`.
" This one matches. [\w.]+ first swallows the whole string, then hands back the
" four characters of '.com' so the literal that follows can have them.
FIND FIRST OCCURRENCE OF PCRE `^[\w.]+\.com$` IN host RESULTS DATA(plain).
" This one matches nothing, on any input at all. [\w.] includes the dot, so the
" possessive quantifier swallows '.com' too and then refuses to give a single
" character back. The pattern compiles, the FIND simply sets sy-subrc = 4, and
" a test that only asks "did it dump?" will not notice.
FIND FIRST OCCURRENCE OF PCRE `^[\w.]++\.com$` IN host RESULTS DATA(swallowed).
" Where it pays: \d can never match the separator, so forbidding the
" backtracking costs nothing -- and a long run of digits stops being a place
" where a FAILING match has to try every split of the run.
DATA(amount) = `1234567890.55`.
FIND FIRST OCCURRENCE OF PCRE `^\d++\.\d++$` IN amount RESULTS DATA(number).
" The atomic group is the same instruction with a wider scope: everything
" inside it is matched once and never reconsidered.
FIND FIRST OCCURRENCE OF PCRE `^(?>\d+)\.\d+$` IN amount RESULTS DATA(atomic).
That file is checked by abaplint and never run — nothing in this repository, and nothing on a GitHub runner, has an ABAP system. Note the older REGEX addition, obsolete since 7.55, is a different engine with different answers; a pattern with ++ in it deserves a test after that migration, not an assumption.
The measured summary¶
| Engine (version measured) | (?>…) |
a++ |
What a++a does to "aa" |
|---|---|---|---|
| Perl 5.42 | yes | yes | no match |
PCRE2 10.48, so ABAP PCRE |
yes | yes | no match |
| Java 25 | yes | yes | no match |
| Ruby 4.0 | yes | yes | no match |
Python re 3.12+ |
yes | yes | no match |
Python re 3.10 and earlier |
no — compile error | no — compile error | — |
| JavaScript (node 20) | no — SyntaxError |
no — SyntaxError |
— |
Rust fancy-regex 0.16.2 |
yes | yes | no match |
Rust regex 1.13.1 |
no — "unrecognized flag" | compiles, and is not possessive | match |
Go regexp 1.25.5 |
no — named in the error | no — named in the error | — |
Any of them, via (?=(a+))\1 |
— | — | no match |
Every cell above came from a program in examples/, a dated real run quoted on this page, or the cross-engine probe in who supports what.
When not to reach for them¶
- When the quantified thing overlaps what follows it.
[\w.]++\.com,.*+;,[a-z]++e— each one matches nothing, forever. If you cannot say out loud why the next element can never be matched by the quantifier, do not make it possessive. - As a blanket optimisation. Sprinkling
+after every quantifier is not a speedup pass; it is a semantics change with a speedup sometimes attached. Change one quantifier, run the tests, keep the diff small enough to revert. - When the pattern has to run in JavaScript too. Use the lookahead emulation in both places rather than maintaining two patterns that differ by two characters.
- When the real problem is the pattern's shape.
^(a+)+$does not need an atomic group; it needs to not be^(a+)+$. Removing a nested quantifier or making two alternatives mutually exclusive fixes the search and leaves a pattern the next reader can follow. - When you are on an engine that cannot explode. In Go's
regexpand Rust'sregexthere is no backtracking to suppress, so the only thing a possessive quantifier can do there is confuse — or, inregex, quietly mean something else.
The place they are unambiguously right: a long, deterministic run — digits, hex, whitespace, a quoted string's contents — inside a pattern that will often fail. \d++\.\d+, "[^"]*+", \s*+\S. Those are the cases where the saved backtracking positions were never going to be used, and keeping them is pure cost.
See also¶
- What a backreference costs — why backtracking exists at all, and the measurement these constructs cut short.
- Who supports what — the same two constructs in the library's wider capability probe, alongside nine others.
- Lookahead and lookbehind — the assertion the JavaScript emulation is built from, and the width rules that constrain it.
- What a backreference is — the other half of that emulation.
- Backtracking — the rest of this chapter.
- perlre ↗ and pcre2pattern ↗ — the two documents that define the syntax everyone else copied.