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.

The pattern open parenthesis, open parenthesis, INC or CHG, close, hyphen, open, backslash d plus, close, close, space, open, open or closed, close. Group 1 starts at the first opening parenthesis and spans the whole ticket ID. Group 2 starts at the second and holds INC or CHG. Group 3 holds the digits and group 4 holds the status word. On the text CHG-1042 closed, group 1 is CHG-1042, group 2 is CHG, group 3 is 1042 and group 4 is closed. ( ( INC|CHG ) - ( \d+ ) ) ( open|closed ) 2 type 3 number 4 status 1 ticket CHG-1042 closed → 1=CHG-1042 2=CHG 3=1042 4=closed
Count the opening parentheses (in the accent color) from left to right: that gives the group numbers. Group 1 contains groups 2 and 3, so it starts first and gets the lowest number.

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.