Regex Capture Groups: Numbered, Named, Non-Capturing, Backreferences and Replacements
Parentheses in a regular expression do two jobs at once: they group part of the pattern so a quantifier or alternation applies to it, and they capture the text that matched so you can use it later. That double role causes almost every capture-group bug: numbers that shift when someone adds a parenthesis, repeated groups that "lose" values, and replacements that work in one language and not another.
Numbered groups
Each opening parenthesis creates a group, numbered left to right by the position of its (. Group 0 is always the whole match.
const m = /(\d{4})-(\d{2})-(\d{2})/.exec('Due: 2026-09-28');
m[0]; // '2026-09-28' whole match
m[1]; // '2026'
m[2]; // '09'
m[3]; // '28'
m.index // 5: where the match starts
import re
m = re.search(r'(\d{4})-(\d{2})-(\d{2})', 'Due: 2026-09-28')
m.group(1), m.groups() # '2026', ('2026', '09', '28')
Nested groups count by their opening parenthesis too: in ((\d{4})-(\d{2})), group 1 is 2026-09, group 2 is 2026 and group 3 is 09. The weakness of numbers is fragility. Adding a parenthesis near the start of the pattern renumbers every later group and silently breaks code that uses them.
Named groups
Named groups fix that fragility and make the pattern self-documenting. The syntax depends on the engine.
JavaScript, PCRE (PHP), Java, .NET (?<year>\d{4})
Python, Go (?P<year>\d{4}) Go 1.22+ also accepts (?<year>...)
const { groups } = /(?<year>\d{4})-(?<month>\d{2})/.exec('2026-09');
groups.year; // '2026'
# Python
re.match(r'(?P<year>\d{4})-(?P<month>\d{2})', '2026-09').group('year') # '2026'
re.match(r'(?P<year>\d{4})-(?P<month>\d{2})', '2026-09').groupdict() # {'year': '2026', 'month': '09'}
A named group still has a number, so $1 and groups.year refer to the same text. Python does not accept (?<name>…) and raises an error at compile time; use (?P<name>…).
Non-capturing groups: (?:…)
Often parentheses only group, to apply + to a sequence or to limit an alternation, and you do not need the text. (?:…) groups without capturing.
/^(?:https?|ftp):\/\/([^/]+)/ // group 1 is the host; the scheme is not captured
/(?:\d{3}-){2}\d{4}/ // repeats '123-' twice without creating groups
Using them by default whenever you do not need the value keeps the numbers of the groups you do use stable, and saves the engine a little work. Lookarounds, (?=…), (?!…), (?<=…) and (?<!…), do not capture either.
A repeated group keeps only the last repetition
This is the most surprising behaviour. When a group has a quantifier, it captures on every iteration, but only the last one is kept.
/(\d)+/.exec('123')[1]; // '3', not '123' or ['1','2','3']
/(\w+,)+/.exec('a,b,c,')[1]; // 'c,'
If you want the whole block, put the quantifier inside the group: (\d+). If you want each item separately, do not use a repeated group. Find every match of a simpler pattern with matchAll in JavaScript or findall/finditer in Python, or split the text. Among common engines, only .NET keeps every capture of a repeated group, in Group.Captures.
[...'a@x.com b@y.com'.matchAll(/(\w+)@(\w+)\.com/g)].map(m => [m[1], m[2]]);
// [['a','x'], ['b','y']]
re.findall(r'(\w+)@(\w+)\.com', 'a@x.com b@y.com')
# [('a', 'x'), ('b', 'y')] -> with groups, findall returns tuples of groups
Groups that did not participate
In (a)|(b) matched against b, group 1 does not take part in the match. Languages represent that differently: undefined in JavaScript, None in Python, null in Java and an empty string in PHP. Code that concatenates a group without checking can end up writing "undefined" or "None" into data.
PHP's empty string is especially misleading, because it cannot distinguish "the group did not participate" from "the group captured empty text". If you need the difference, pass the PREG_UNMATCHED_AS_NULL flag to preg_match.
Backreferences and replacements
Inside the pattern, \1 refers to whatever group 1 captured. It detects repetition, such as doubled words or quotes that must close with the same character they opened with.
/\b(\w+)\s+\1\b/i // doubled words: 'the the'
/(['"])(.*?)\1/ // text in single or double quotes, closed the same way
In replacements the syntax changes from language to language, which is where copied examples most often go wrong:
JavaScript '2026-09'.replace(/(\d{4})-(\d{2})/, '$2/$1') // '09/2026'
'2026-09'.replace(/(?<y>\d{4})-(?<m>\d{2})/, '$<m>/$<y>')
Python re.sub(r'(\d{4})-(\d{2})', r'\2/\1', '2026-09')
re.sub(r'(?P<y>\d{4})-(?P<m>\d{2})', r'\g<m>/\g<y>', '2026-09')
PHP preg_replace('/(\d{4})-(\d{2})/', '$2/$1', '2026-09') // numbers only
Java/.NET "$2/$1" and "${m}/${y}" for named groups
Two traps: in Python, \10 can mean group 10 or group 1 followed by 0, and \g<1>0 removes the ambiguity. In JavaScript, a literal $ in the replacement must be written $$.
Good practice with groups
Switch to named groups once a pattern has more than two groups or someone else will maintain it: code reading groups.year keeps working when a parenthesis is added in front. Use (?:…) for every group whose text you do not need, so the numbers you rely on never shift.
Do not try to extract a list with one pattern full of repeated groups. A simple pattern applied with matchAll or finditer is more readable, easier to test and faster. Always test optional groups with input where they do not participate, because that is where undefined and None appear in production. And remember that backreferences force the engine to backtrack, and some engines, such as Go's RE2, do not support them at all.
Sources and further reading
MDN's regular expressions guide documents groups, named groups and JavaScript replacement patterns. The Python re documentation describes (?P<name>…), groupdict() and \g<name>. The PHP manual documents preg_match, preg_replace and PREG_UNMATCHED_AS_NULL, and the .NET documentation describes Group.Captures.
Frequently asked questions
What is a capture group in regex?
A part of the pattern in parentheses that stores the text it matched, so you can use it in code, in a replacement (`$1`, `\1`) or inside the pattern as a backreference.
How do I create a named capture group?
Use `(?<name>…)` in JavaScript, PHP, Java and .NET, and `(?P<name>…)` in Python. Access it with `groups.name`, `group('name')`, or `$<name>` in JavaScript replacements.
What is a non-capturing group?
`(?:…)` groups part of the pattern, for example to repeat it or limit an alternation, without storing the text or creating a group number.
Why does my repeated group only return the last value?
A quantified group such as `(\d)+` is overwritten on every repetition and keeps only the last. Put the quantifier inside the group, or use `matchAll`/`findall` to get every match.
How do I use a capture group in a replacement?
`$1` in JavaScript, PHP, Java and .NET; `\1` or `\g<1>` in Python. For named groups: `$<name>` in JavaScript, `\g<name>` in Python and `${name}` in Java and .NET.
What does a group that did not participate return?
`undefined` in JavaScript, `None` in Python, `null` in Java and an empty string in PHP, unless you use `PREG_UNMATCHED_AS_NULL`. Check before using the value.