creatorvalet

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.

Updated 2026-08-09

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.