Regex non-capturing group
Grouping and capturing are separate jobs sharing one syntax. What (?:…) buys, the numbering bug it prevents, and whether it is measurably faster.
A pair of parentheses in a regular expression does two unrelated things at once. It marks off a region so a quantifier or an alternation knows what it applies to, and it records whatever that region matched so you can read it back afterwards. Those are separate jobs. You almost always want the first one. You want the second one perhaps a third of the time, and the rest of the time you get it anyway.
(?:…) is the first job with the second one switched off. Every explanation says that much and
then stops, which leaves the impression that it is a micro-optimization for people who like
tidy output. It is not. The reason to reach for it is that an unwanted capture group changes
the numbering of every group after it, and numbering is the one thing in a regular expression
that can be wrong without producing an error. If you want the token itself in a reference table
with a column per engine, that is the cheat sheet; this page is about the
mistake the token exists to prevent.
The parenthesis you only wanted for scope
Alternation is the clearest case, because there is no way to write it without parentheses:
/(?:cat|dog)s/.exec("dogs") // ["dogs"]
/(cat|dog)s/.exec("dogs") // ["dogs", "dog"]
Both patterns say the same thing about the text — one of two words, then an s. The brackets
are there so that cat and dog are the alternatives rather than cat and dogs. The second
form additionally hands you dog in slot one, which nobody asked for and which the rest of the
pattern now has to route around.
The same thing happens the moment a quantifier needs something bigger than one character to repeat, and it shows up in places you would not think to look:
"a1b2c".split(/(\d)/) // ["a", "1", "b", "2", "c"]
"a1b2c".split(/(?:\d)/) // ["a", "b", "c"]
split interleaves the contents of every capture group into its own result array. That is a
documented feature and occasionally exactly what you want; met by accident, it is a function
that returned five items when you were expecting three.
Backreferences count the same way, so an accidental group shifts what \1 points at:
/(\w+) (\w+) \1/.test("go now now") // false
/(?:\w+) (\w+) \1/.test("go now now") // true
Same subject, same intent — find a word repeated twice — and the first one silently checks the wrong word.
The number moves, and nothing says so
Here is the failure worth the whole page. Start with a pattern that renames one file extension:
"hero.png".replace(/(\w+)\.png/, "$1.webp") // "hero.webp"
Support JPEGs as well, which needs an alternation, which needs brackets:
"hero.png".replace(/(\w+)\.(png|jpg)/, "$1.webp") // "hero.webp"
Still correct, because the new group landed after the one being read. Now allow an optional folder in front, which needs brackets too — and this time they land in front:
"img/hero.png".replace(/(.*\/)?(\w+)\.(png|jpg)/, "$1.webp") // "img/.webp"
No exception, no warning, a string that still ends in .webp. $1 is now the directory and
the filename is gone. Written with the scope-only bracket the pattern needed, the same edit is
inert:
"img/hero.png".replace(/(?:.*\/)?(\w+)\.(?:png|jpg)/, "$1.webp") // "hero.webp"
Destructuring fails identically and looks even more innocent, because array destructuring is positional by definition:
const [, name, ext] = /(.*\/)?(\w+)\.(png|jpg)/.exec("hero.png")
// name === undefined, ext === "hero"
The worst version of this is a pattern assembled from strings, where the person writing a fragment cannot see the numbering of the finished expression at all. Fragments that group for scope only compose without arithmetic:
const DIR = '(?:.*\\/)?'
const NAME = '([\\w-]+)'
const EXT = '\\.(?:png|jpg)'
new RegExp('^' + DIR + NAME + EXT + '$').exec("assets/img/hero-2x.png")
// ["assets/img/hero-2x.png", "hero-2x"]
One capture group in the whole assembly, and it is the one thing anybody wanted. The working
rule that falls out: a bracket you added for scope should be (?:, and a bare ( should be
a promise that something reads the result back.
Is a non-capturing group faster? Measured, not repeated
“Non-capturing groups are faster” is on every page about them, usually without a number attached. Here are numbers. Node 22, a 3.3 MB subject with 200,000 matches, median of nine runs, the same pattern spelled both ways. Read the columns against each other and not as absolutes — the milliseconds belong to one machine on one afternoon, and a re-run on a busy one moved every figure up while leaving the ratios where they were. The ratio is the finding:
| How the matches were taken | Capturing | Non-capturing | Gap |
|---|---|---|---|
exec in a loop |
10.5 ms | 6.7 ms | 36% |
String.match with g |
6.7 ms | 6.5 ms | 2% |
test in a loop |
5.1 ms | 4.7 ms | 7% |
replace with g |
8.0 ms | 7.9 ms | 1% |
The claim survives, but only in one row, and that row explains the other three. exec builds a
result array per match with every group in it. String.match with the g flag returns the
whole matches only, and replace with a literal replacement never asks for a group — so
neither of them pays for captures they do not read, and the gap collapses to noise. The cost
is not in the matching. It is in handing you the results.
Which is confirmed by counting groups rather than switching them off:
| Pattern shape | exec loop |
|---|---|
| No groups at all | 6.9 ms |
| Three non-capturing | 6.8 ms |
| Three capturing | 11.1 ms |
| Six non-capturing | 6.8 ms |
| Six capturing | 13.7 ms |
The non-capturing rows sit on the no-groups row exactly, whatever the count. Each capture adds
a little over a millisecond per 200,000 matches, so the three-group pattern as a whole runs
about 21 nanoseconds slower per match. To save four milliseconds you need two hundred
thousand of them. Python 3.10 agrees on the shape:
re.findall runs 38% faster without groups, because with them it builds a tuple per match,
while a bare finditer that never touches a group differs by 3%.
There is one place the difference is real and still useless. A pattern with catastrophic backtracking gets marginally cheaper without captures, because there is less state to save and restore on each retry:
^(a+)+$ against n letters and a b |
Capturing | Non-capturing |
|---|---|---|
| n = 22 | 27 ms | 22 ms |
| n = 24 | 110 ms | 87 ms |
| n = 26 | 440 ms | 336 ms |
Every two characters quadruples the time in both columns. A fifth off an exponential is still
an exponential, and a pattern that hangs will hang either way. Use (?: for the numbering.
Take the speed as a rounding error you were going to get anyway.
Named groups, and where the third line goes
(?<name>…) is the modern answer to counting, and it is worth knowing what it does and does
not fix. It does not stop the numbering:
const m = /(?<name>\w+)\.(?:png|jpg)/.exec("hero.png")
m.groups // { name: "hero" }
m[1] // "hero" — the number is still there
A named group occupies its position exactly as a bare one does, in JavaScript, in PCRE and in
Python alike. What it gives you is a handle that does not move when the position does, which is
the actual complaint. It also refuses collisions rather than silently picking one: (?<a>x)|(?<a>y)
is Duplicate capture group name in Node 22, even though the two alternatives can never both run.
It is not free either. The same benchmark, same subject, three spellings of the same three
groups: non-capturing 6.4 ms, numbered 10.6 ms, named 14.3 ms. Naming costs more than
numbering because the engine builds the groups object in addition to the numbered array, not
instead of it.
So the line has three positions rather than two. Name a group you will read back by name.
Number one you will read back immediately and locally, as in a two-part destructure right next
to the pattern. Everything else — every bracket you typed because an alternation or a
quantifier needed to know its own extent — takes (?:.
Two engines got tired of this and added a switch
PCRE treats accidental capture as a problem worth solving in the engine. Measured against PHP 7.4 with PCRE 10.35:
preg_match('/(?n)(a)(b)/', 'ab', $m); // ["ab"]
preg_match('/(a)(b)/', 'ab', $m); // ["ab", "a", "b"]
(?n) turns off automatic capture for the rest of the pattern, so every bare (…) behaves as
if it were written (?:…) and only named groups still record anything. It is the inverted
default: capture becomes the thing you opt into.
The branch reset is the other one, and it fixes a numbering problem no amount of discipline does — alternatives that each need a capture but should share a slot:
preg_match('/(?|(a)|(b))x/', 'bx', $m); // ["bx", "b"]
preg_match('/((a)|(b))x/', 'bx', $m); // ["bx", "b", "", "b"]
Without it you get three groups for one value, two of which are junk on any given match.
| Control over numbering | JS | PCRE | Py |
|---|---|---|---|
(?:…) — group, record nothing |
yes | yes | yes |
| A named group also takes a number | yes | yes | yes |
(?n) — bare groups stop capturing |
— | yes | — |
| Branch reset — alternatives share a slot | — | yes | — |
Both of the PCRE-only rows throw in JavaScript with the same unhelpful Invalid group, and
raise unknown extension in Python’s re. That is the good outcome: they fail loudly. The
row above them is the quiet one — a named group still consuming a number is what makes people
think renaming their groups fixed the fragility, when it only relocated it.
Reading the numbering instead of counting it
Counting brackets by eye is the part of this that people get wrong, and it is unnecessary
work. Paste a pattern into the regex tester and every group is listed by its
number, with its name when it has one and with its own start and end position, so a group that
moved is something you see rather than something you deduce. Try the folder-aware version of
the pattern above against img/hero.png: group one comes back as img/, which is the entire
bug, visible in a second, before it reaches a replacement string.
Two neighbors cover the sides of this that have their own traps. The replacement half — what
$1 and $& mean once the groups exist, and why a replacer function’s argument positions
shift with the group count — is on the regex replace page. And if you are
using brackets to say this must be here but must not be part of the match, that is not a
group at all but an assertion, worked through in
lookahead and lookbehind.