Skip to content

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 = 14

Count 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 401

Names 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 POST

A 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: \1 through \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/2025

Field notes

  • An optional group that didn't participate is undefined in JS and None in Python. Template it into a string and you ship the literal text "undefined".
  • In Python, r"\10" means group 10, not group 1 followed by 0. Write r"\g<1>0".
  • $ is special in JS replacement strings. If the replacement comes from user data, pass a function: s.replace(re, () => userText).
  • matchAll throws a TypeError if the regex lacks the g flag. That's a feature — it stops you from looping forever.