Groups, Captures and Backreferences
Grouping parts of a pattern, capturing what they matched, naming groups, non-capturing groups, and backreferences that match the same text again.
Grouping
A
quantifier
applies to the single item just before it. In ab+, the
+ repeats only the b: it matches
ab, abb, abbb. To repeat a
longer piece, put it in parentheses: (ab)+ matches
ab, abab, ababab. The same
holds for alternation:
parentheses decide how far a | reaches.
A rough IPv4-shaped pattern shows the difference.
(\d{1,3}\.){3} means "one to three digits and a dot,
three times"; without the parentheses, {3} applies to
the dot alone.
import re
print(re.fullmatch(r"(\d{1,3}\.){3}\d{1,3}", "192.0.2.17") is not None)
print(re.fullmatch(r"\d{1,3}\.{3}\d{1,3}", "192.0.2.17") is not None)
print(re.fullmatch(r"\d{1,3}\.{3}\d{1,3}", "192...17") is not None)
True False True
This pattern checks the shape of an address only: it also
accepts 999.1.1.1. For real
IPv4 addresses, see
Practical Patterns, which ends with a proper parser.
Capture groups
Plain parentheses also remember what they matched. A capture group is a parenthesized part of a pattern whose matched text the engine stores, so your program can read it after the match (or the pattern can refer to it again, see backreferences). This is how you pull fields out of a log line: one regex, several pieces of data.
Groups are numbered by their opening parenthesis,
counting from the left, starting at 1. Nested groups therefore get
numbers in the order you read their (. Group 0 is not a
group in the pattern but the whole match.
Each language hands you the groups in its own way. Python returns a match object, JavaScript an array with extra properties, and PHP fills an array you pass in. The same pattern on the same change-log line:
import re
line = "2026-10-02 09:12 CHG-1042 closed"
m = re.search(r"((INC|CHG)-(\d+)) (open|closed)", line)
print(m.group(0))
print(m.group(1), m.group(2), m.group(3), m.group(4))
print(m.groups())
print(m.span(3))
CHG-1042 closed
CHG-1042 CHG 1042 closed
('CHG-1042', 'CHG', '1042', 'closed')
(21, 25)
const line = "2026-10-02 09:12 CHG-1042 closed";
const m = line.match(/((INC|CHG)-(\d+)) (open|closed)/);
console.log(m[0]);
console.log(m[1], m[2], m[3], m[4]);
console.log(m.length, m.index);
CHG-1042 closed CHG-1042 CHG 1042 closed 5 17
<?php
$line = "2026-10-02 09:12 CHG-1042 closed";
preg_match('/((INC|CHG)-(\d+)) (open|closed)/', $line, $m);
echo implode(" | ", $m), "\n";
echo count($m), "\n";
CHG-1042 closed | CHG-1042 | CHG | 1042 | closed 5
The details of each API (re.finditer,
matchAll, preg_match_all) are on
Regex in Python, JavaScript and PHP.
A repeated group keeps its last match
A capture group stores one piece of text. If the group is repeated by a quantifier, each repetition overwrites the previous one, and you only get the last:
import re
m = re.fullmatch(r"(\d{1,3}\.){3}(\d{1,3})", "192.0.2.17")
print(m.groups())
print(re.findall(r"\d{1,3}", "192.0.2.17"))
('2.', '17')
['192', '0', '2', '17']
To get every repetition, match the repeated part on its own with a find-all function, as in the second line, or split the text.
Groups that did not take part
In a pattern with alternatives or optional parts, some groups may not take part in the match at all. The languages report that differently, and it is a common source of bugs:
import re
m = re.search(r"(INC|CHG)-(\d+)( urgent)?", "INC-31877 portal down")
print(m.groups())
print(m.group(3) is None)
('INC', '31877', None)
True
const m = "INC-31877 portal down".match(/(INC|CHG)-(\d+)( urgent)?/);
console.log(m.slice(1));
console.log(m[3] === undefined);
[ 'INC', '31877', undefined ] true
<?php
preg_match('/(INC|CHG)-(\d+)( urgent)?/', "INC-31877 portal down", $m);
var_dump(count($m), isset($m[3]));
preg_match('/(INC|CHG)-(\d+)( urgent)?/', "INC-31877 portal down", $m, PREG_UNMATCHED_AS_NULL);
var_dump(count($m), $m[3]);
int(3) bool(false) int(4) NULL
Python gives None and JavaScript
undefined. PHP by default drops unmatched groups at the
end of the array and fills unmatched groups in the middle with an
empty string, so you cannot tell "did not take part" from "matched
empty text". The flag PREG_UNMATCHED_AS_NULL makes PHP
report NULL instead.
Named groups
Numbers break as soon as someone adds a group earlier in the pattern. A named group is a capture group with a name, which you use instead of (or as well as) its number. Names document the pattern and survive edits. The syntax differs per flavor:
| Flavor | Define | Refer back in the pattern | Read the result |
|---|---|---|---|
Python re |
(?P<type>…) |
(?P=type) |
m.group("type"), m["type"],
m.groupdict()
|
| JavaScript | (?<type>…) |
\k<type> |
m.groups.type |
| PHP (PCRE2) |
(?<type>…) or
(?P<type>…)
|
\k<type> |
$m['type'] (and still $m[1])
|
import re
pattern = r"(?P<type>INC|CHG)-(?P<number>\d+) (?P<status>open|closed)"
m = re.search(pattern, "2026-10-02 09:12 CHG-1042 closed")
print(m.group("type"), m["number"])
print(m.groupdict())
print(m.group(2)) # names do not replace the numbers
CHG 1042
{'type': 'CHG', 'number': '1042', 'status': 'closed'}
1042
const pattern = /(?<type>INC|CHG)-(?<number>\d+) (?<status>open|closed)/;
const { type, number, status } = "2026-10-02 09:12 CHG-1042 closed".match(pattern).groups;
console.log(type, number, status);
CHG 1042 closed
<?php
preg_match('/(?<type>INC|CHG)-(?<number>\d+)/', "CHG-1042 closed", $m);
print_r($m);
Array
(
[0] => CHG-1042
[type] => CHG
[1] => CHG
[number] => 1042
[2] => 1042
)
PHP stores every named group twice, under its name and under its number, which matters when you loop over the array or count it.
import re
re.compile(r"(?<type>INC|CHG)-\d+")
re.error: unknown extension ?<t at position 1
The opposite direction is safe in PHP, which accepts both spellings,
but not in JavaScript, which only knows
(?<name>…). Check the documentation of the Python
version you run before relying on either form.
Non-capturing groups
Often you need parentheses only for grouping, for example around
alternatives, and have no use for the captured text. A
non-capturing group,
written (?:…), groups like ordinary parentheses but
stores nothing and gets no number. It keeps the numbering of the
groups you do care about stable, and it avoids a nasty surprise in
Python: when a pattern contains groups,
re.findall returns the groups, not the whole
matches.
import re
log = "meter KE-0042 read 318 kWh, meter KM-0107 read 92 kWh"
print(re.findall(r"(KE|KM)-\d{4}", log))
print(re.findall(r"(?:KE|KM)-\d{4}", log))
print(re.findall(r"((?:KE|KM)-\d{4}) read (\d+)", log))
['KE', 'KM']
['KE-0042', 'KM-0107']
[('KE-0042', '318'), ('KM-0107', '92')]
With one group, findall returns that group's text; with
several, a tuple per match; with none, the whole match. JavaScript's
match with the g flag and PHP's
preg_match_all always include the whole match, so the
surprise is mostly a Python one. Still, (?:…) is a good
habit in every flavor: it says "grouping only" to the next reader.
Backreferences
A backreference is a part of a
pattern that matches exactly the same text that an earlier capture
group matched, written \1 for group 1,
\2 for group 2 and so on. It does not repeat the
pattern of the group but the text it captured.
That makes it possible to find things like doubled words or a
closing quote that matches the opening one.
| Pattern | Matches | Does not match |
|---|---|---|
\b(\w+) \1\b |
the the, is is |
the then, This is |
(["']).*?\1 |
'Den Haag', "it's" |
'Den Haag" |
import re
note = "Meter replaced, the the old unit is is returned. This is fine."
for m in re.finditer(r"\b(\w+) \1\b", note):
print(repr(m.group(0)), "at", m.start())
print(re.sub(r"\b(\w+) \1\b", r"\1", note))
fields = """name='Kestrel Energy' city="Den Haag" note="it's ok\""""
print([m.group(0) for m in re.finditer(r"([\"']).*?\1", fields)])
'the the' at 16 'is is' at 33 Meter replaced, the old unit is returned. This is fine. ["'Kestrel Energy'", '"Den Haag"', '"it\'s ok"']
The \b at both ends matters: without it,
This is would match as is is, because the
end of This followed by a space and
is looks like a doubled word. In JavaScript and PCRE
you can also refer to a named group with
\k<name>; Python uses (?P=name).
const note = "Customer called called back about the the invoice";
console.log(note.match(/\b(?<word>\w+) \k<word>\b/g));
[ 'called called', 'the the' ]
Backreferences in grep
POSIX defines backreferences for basic regular expressions, where
groups are written \(…\). GNU grep also accepts them
with -E, as an extension. This is a quick way to find
typos in a file of ticket notes:
printf '%s\n' 'Meter replaced, the the old unit returned' 'Reading checked twice' 'Customer called called back' > notes.txt
grep -n -E '\b(\w+) \1\b' notes.txt
echo ---
grep -n '\(called\) \1' notes.txt
1:Meter replaced, the the old unit returned 3:Customer called called back --- 3:Customer called called back
Groups in replacements
Captured text can also be reused in the replacement of a
search-and-replace, which is how you reorder fields. Inside the
pattern the reference is \1 in every flavor above; in
the replacement string the syntax differs: \1 or
\g<name> in Python, $1 or
$<name> in JavaScript, $1,
\1 or ${1} in PHP. Turning a Dutch date
into ISO 8601 order:
const line = "Meter KE-0042 read on 02-10-2026 and 15-10-2026";
console.log(line.replace(/(\d{2})-(\d{2})-(\d{4})/g, "$3-$2-$1"));
console.log(line.replace(/(?<d>\d{2})-(?<m>\d{2})-(?<y>\d{4})/g, "$<y>-$<m>-$<d>"));
Meter KE-0042 read on 2026-10-02 and 2026-10-15 Meter KE-0042 read on 2026-10-02 and 2026-10-15
The result is in ISO 8601 order (year, month, day), which also sorts correctly as text. Replacement syntax, callbacks and the traps per language are on Search and Replace.