Lesson 4 of 9 · 22m
Capture Groups, Backreferences, and Replacement
Numbered and named groups, non-capturing groups, the last-iteration trap, and the replacement syntax that differs in every tool.
Matching tells you yes or no. Capturing is where regex earns its keep: pulling structure out of unstructured text. It's also where the subtle bugs live — a group that renumbers when a teammate adds one more pair of parentheses, or a repeated group that quietly keeps only its last iteration.
Groups are numbered by their opening parenthesis
((\d{4})-(\d{2}))-(\d{2}) input: 2025-03-14
^^ ^
12 3 4
group 1 = 2025-03 group 2 = 2025 group 3 = 03 group 4 = 14Count left parentheses from the left. Insert a group near the front and every later number shifts — which is why positional groups belong in throwaway one-liners, not production code.
Use named groups
const line = '203.0.113.42 - - [14/Mar/2025:09:26:53 +0000] "POST /api/v1/login HTTP/1.1" 401 112 "-" "python-requests/2.31.0"';
const re = /^(?<ip>\S+) \S+ \S+ \[(?<time>[^\]]+)\] "(?<method>[A-Z]+) (?<path>\S+) [^"]*" (?<status>\d{3}) (?<bytes>\d+|-)/;
const { ip, method, path, status } = line.match(re).groups;
console.log(ip, method, path, status); // 203.0.113.42 POST /api/v1/login 401Names survive refactors and document intent. Python spells them (?P<ip>...); JavaScript, PCRE, and .NET use (?<ip>...). Access them as m.groups (JS) or m.groupdict() (Python).
Group without capturing
Use (?:...) when you only need grouping for alternation or a quantifier. It also fixes the most common precedence bug:
^GET|POST$ means (^GET) or (POST$) — matches "GET-anything" and "anything-POST"
^(?:GET|POST)$ means exactly GET or exactly POSTA repeated group keeps only its last iteration
console.log(/^(\d+\.)+\d+$/.exec("10.0.12.7")[1]); // "12." — not all octets
console.log([..."10.0.12.7".matchAll(/\d+/g)].map(m => m[0])); // ["10", "0", "12", "7"]If you need every repetition, iterate with a global match (matchAll, re.finditer) or split.
Backreferences and replacement
A backreference re-matches whatever a group captured. \b(\w+)\s+\1\b finds doubled words ("the the") in a docs lint. In replacements, every tool spells it differently:
- JavaScript:
$1,$<name>,$&for the whole match,$$for a literal dollar - Python:
\1,\g<name>,\g<0>for the whole match - sed:
\1through\9,&for the whole match - Perl:
$1,$+{name}
const iso = "2025-03-14";
console.log(iso.replace(/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/, "$<d>/$<m>/$<y>")); // 14/03/2025Field notes
- An optional group that didn't participate is
undefinedin JS andNonein Python. Template it into a string and you ship the literal text "undefined". - In Python,
r"\10"means group 10, not group 1 followed by0. Writer"\g<1>0". $is special in JS replacement strings. If the replacement comes from user data, pass a function:s.replace(re, () => userText).matchAllthrows a TypeError if the regex lacks thegflag. That's a feature — it stops you from looping forever.