Mert Özen Further With Every Line
Developer Tools Regular Expressions — #03

Groups and Backreferences: Capturing, Named Groups, Non-capturing Groups, Alternation and $1/${name} in Replacements

Mert Özen Nov 15, 2026 12 min 5 views
Groups and Backreferences: Capturing, Named Groups, Non-capturing Groups, Alternation and $1/${name} in Replacements

Capturing groups are numbered by the order of their opening parentheses, but .NET puts named groups last, so the same $2/$1 replacement gives different results in .NET and JavaScript. We compare named group syntaxes, ES2025 duplicate group names, finding repeated words with backreferences, POSIX's longest-match rule and the replacement syntax of .NET, JavaScript, sed and VS Code with real output, and fix author names in Bookstore's CSV.

Version note: The examples in this post were run with .NET 10 (SDK 10.0.404), Node.js 26.13 LTS, GNU grep 3.12, PCRE2 10.49, GNU sed 4.10, ripgrep 15.2.0 and Python 3.14. The grep, sed, rg and pcre2test commands run on Linux, in WSL or in Git Bash.

Guess First

In the reports of Bookstore, the online bookstore used as the shared example across this site's series, a period is stored as 2026-11, but the screen should show 11/2026. The backend and the Angular side use the same pattern and the same replacement string:

pattern:     (?<year>\d{4})-(\d{2})
replacement: $2/$1

The year group has a name, the month group does not. .NET calls Regex.Replace and JavaScript calls String.prototype.replace with these two. Do both sides produce 11/2026?

Capturing Groups and the Numbering Rule

Parentheses do two jobs: they turn the piece inside into a single unit (a quantifier or an alternation applies to all of it) and they capture the matched text. You reach the captured text by its number. Numbers follow the order of the opening parentheses from left to right; the same rule applies to nested groups. In ((\d{4})-(\d{2})) the outer group is 1, the year 2 and the month 3. Group 0 is always the whole match.

The rule is simple, but .NET has an exception:

using System.Text.RegularExpressions;

Regex re = new(@"(?<year>\d{4})-(\d{2})");
Match m = re.Match("2026-11");

// in .NET, named groups are numbered after the unnamed ones
Console.WriteLine($"1={m.Groups[1].Value} 2={m.Groups[2].Value} year={m.Groups["year"].Value}");
Console.WriteLine(string.Join(",", re.GetGroupNames()));
Console.WriteLine(re.Replace("2026-11", "$2/$1"));

// the same name in two alternatives
Regex either = new(@"(?<year>\d{4})-\d{2}|\d{2}/(?<year>\d{4})");
Console.WriteLine($"{either.Match("2026-11").Groups["year"].Value} {either.Match("11/2026").Groups["year"].Value}");
$ dotnet run dotnet/group-numbers.cs
1=11 2=2026 year=2026
0,1,year
2026/11
2026 2026
$ node js/group-numbers.mjs
1=2026 2=11 year=2026
11/2026
2026 2026
SyntaxError: Invalid regular expression: /(?<year>\d{4})-(?<year>\d{2})/: Duplicate capture group name

The answer to the guess: .NET wrote 2026/11 and JavaScript 11/2026. .NET numbers the unnamed groups first, starting from 1, and the named groups after them. That is why $1 became the month and $2 the year. JavaScript and PCRE2 look only at the order of the opening parentheses, named or not. If you mix named and unnamed groups in a pattern, do not rely on the numbers; either name all of them or none.

Non-capturing Groups: (?:...)

If you only want to group a piece and do not need the capture, write (?:...). (?:paperback|ebook) limits the alternation but does not get a number. That has two benefits. First, when you later add a group at the start of the pattern, the numbers of the other groups do not shift. Second, the engine does not have to store the captured text; the difference is usually small, but measurable for a group repeated hundreds of times in a log line.

In .NET, RegexOptions.ExplicitCapture (inline form (?n)) makes every unnamed parenthesis non-capturing, so only named groups capture. That is one way to get rid of the numbering confusion above for good. We look at inline flags in detail in the fourth post.

Named Group Syntax Across Flavors

There are three ways to write a named group, and not every flavor knows all of them. (?<name>...) works in .NET, JavaScript and PCRE2. (?'name'...) works in .NET and PCRE2. (?P<name>...) is Python's syntax; PCRE2, Go's regexp package and ripgrep's default engine accept it as well. Python does not accept (?<name>...); tried here with Python 3.14:

$ cat python/named-groups.py
import re

m = re.match(r"(?P<year>\d{4})-(\d{2})", "2026-11")
print(m.group(1), m.group(2), m.group("year"))

try:
    re.compile(r"(?<year>\d{4})")
except re.error as e:
    print("re.error:", e)
$ python3 python/named-groups.py
2026 11 2026
re.error: unknown extension ?<y at position 1

Using the same name twice also depends on the flavor. If a date comes in sometimes as 2026-11 and sometimes as 11/2026, you want to capture the year as year in both alternatives. .NET has always allowed that; the last line above returned 2026 for both formats. JavaScript allowed it with ES2025, but only when the names sit in different alternatives: two year groups in the same alternative throw a SyntaxError as shown above. PCRE2 rejects it by default and accepts it with (?J):

$ pcre2test shell/named-groups.txt
PCRE2 version 10.49 2026-09-28 (8-bit)
/(?<year>\d{4})-(\d{2})/
2026-11
 0: 2026-11
 1: 2026
 2: 11

/(?P<year>\d{4})-(?'month'\d{2})/
2026-11
 0: 2026-11
 1: 2026
 2: 11

/(?<y>\d{4})-\d{2}|\d{2}\/(?<y>\d{4})/
Failed: error 143 at offset 30: two named subpatterns have the same name (PCRE2_DUPNAMES not set)
        here: ...{2}\/(?<y> |<--| \d{4})
11/2026

/(?J)(?<y>\d{4})-\d{2}|\d{2}\/(?<y>\d{4})/
11/2026
 0: 11/2026
 1: <unset>
 2: 2026

The first two blocks also show PCRE2's numbering: year is number 1 even though it is named. In the last block the year landed in group 2 and group 1 is <unset>, meaning it did not take part in this match at all. "The group captured an empty string" and "the group did not take part" are different states; Group.Success in .NET and an undefined value in JavaScript tell them apart.

Backreferences: \1 and \k<name>

You can reuse the captured text inside the pattern. \1 means "exactly the text group 1 captured", and \k<name> is the equivalent for a named group. Let's look for words repeated by mistake in Bookstore's catalog descriptions:

using System.Text.RegularExpressions;

foreach (string line in File.ReadLines("data/descriptions.txt"))
{
    // \k<word> must repeat exactly what the group captured
    foreach (Match m in Regex.Matches(line, @"\b(?<word>\w+) \k<word>\b", RegexOptions.IgnoreCase))
        Console.WriteLine($"{line[..13]} \"{m.Value}\" at {m.Index}");
}

// alternation: the first alternative that matches wins
Console.WriteLine(Regex.Match("BKS-2026-000123", "BKS|BKS-2026").Value);

// ^ binds to "paperback" only, $ to "ebook" only
Console.WriteLine($"{Regex.IsMatch("paperback edition", "^paperback|ebook$")} {Regex.IsMatch("paperback edition", "^(?:paperback|ebook)$")}");
$ dotnet run dotnet/repeated-words.cs
9780441172719 "the the" at 50
9780547928227 "is is" at 28
BKS
True False
$ node js/repeated-words.mjs
9780441172719 "the the" at 50
9780547928227 "is is" at 28
BKS
true false
$ grep -nE '\b(\w+) \1\b' data/descriptions.txt
1:9780441172719|A desert planet, a noble family and the the spice that controls the universe.
3:9780547928227|Bilbo Baggins is is drawn into a quest for a dragon's treasure.
$ grep -noP '\b(?<w>\w+) \k<w>\b' data/descriptions.txt
1:the the
3:is is
$ rg '\b(\w+) \1\b' data/descriptions.txt
rg: regex parse error:
    (?:\b(\w+) \1\b)
               ^^
error: backreferences are not supported

Consider enabling PCRE2 with the --pcre2 flag, which can handle backreferences
and look-around.

The \bs matter: without them is is inside this is would match too, because this ends with is. GNU grep -E supports backreferences, but its manual marks them as behavior that POSIX does not define and that is not portable. ripgrep's default engine does not support backreferences at all and suggests switching to PCRE2 with -P. The reason is by design: a backreference requires the engine to look back while matching, which does not fit a guarantee of running in linear time. We cover this split in the fifth post together with the RE2 family.

Backreferences have a limit as well: "the same text" does not mean "the same meaning". that that is sometimes a correct English sentence; the regex cannot know. Searches like this produce a list of candidates, and a person makes the decision.

Alternation and Its Precedence

| is the lowest-precedence operator in regex: it splits the whole pattern on its left and right. ^paperback|ebook$ means "paperback at the start or ebook at the end", not "the whole line is paperback or ebook". That is why paperback edition passed the first pattern in the output above. To limit the alternation, use a group: ^(?:paperback|ebook)$.

The order of the alternatives matters too. .NET, JavaScript and PCRE2 try alternatives from left to right and stop at the first one that matches; that is why BKS|BKS-2026 returned BKS. POSIX asks for the longest match instead:

$ echo 'BKS-2026-000123' | grep -oE 'BKS|BKS-2026'
BKS-2026
$ echo 'BKS-2026-000123' | grep -oP 'BKS|BKS-2026'
BKS
$ echo 'BKS-2026-000123' | rg -o 'BKS|BKS-2026'
BKS
$ echo 'BKS-2026-000123' | sed -E 's/BKS|BKS-2026/[&]/'
[BKS-2026]-000123

grep -E and sed found the longer text with the same pattern. This is one reason why a pattern tested with grep -E gives a different result when you move it to .NET. In backtracking engines the practical rule is: put the longer or more specific alternative first, as in BKS-2026|BKS. ripgrep does not backtrack but applies the same "left alternative first" rule as Perl.

Replacement: $1, ${name} and Syntax per Tool

The replacement string carries its own small language as well, and that language changes from tool to tool:

using System.Text.RegularExpressions;

string price = "Emma: 45";

// group 1 followed by a literal 0?
Console.WriteLine(Regex.Replace(price, @"(\d+)", "$10 TRY"));
Console.WriteLine(Regex.Replace(price, @"(\d+)", "${1}0 TRY"));
// $$ is a literal dollar sign, $0 the whole match, a missing group stays as text
Console.WriteLine(Regex.Replace(price, @"(\d+)", "$$$1"));
Console.WriteLine(Regex.Replace(price, @"(\d+)", "[$0]"));
Console.WriteLine(Regex.Replace(price, @"(\d+)", "$2"));
$ dotnet run dotnet/replace-syntax.cs
Emma: $10 TRY
Emma: 450 TRY
Emma: $45
Emma: [45]
Emma: $2
$ cat js/replace-syntax.mjs
const price = "Emma: 45";

console.log(price.replace(/(\d+)/, "$10 TRY"));
console.log(price.replace(/(\d+)/, "$010 TRY"));
console.log(price.replace(/(\d+)/, "$$$1"));
console.log(price.replace(/(\d+)/, "[$&]"));
console.log(price.replace(/(\d+)/, "$2"));
console.log(price.replace(/(?<amount>\d+)/, "$<amount>0 TRY"));
$ node js/replace-syntax.mjs
Emma: 450 TRY
Emma: 450 TRY
Emma: $45
Emma: [45]
Emma: $2
Emma: 450 TRY
$ echo 'Emma: 45' | sed -E 's/([0-9]+)/\10 TRY/'
Emma: 450 TRY
$ echo 'Emma: 45' | sed -E 's/([0-9]+)/[&]/'
Emma: [45]

The same $10 stayed as text in .NET and became "group 1 and 0" in JavaScript. .NET first looks for group 10 and, when it does not find it, leaves the expression as a literal; JavaScript falls back to a single digit when the two-digit number is invalid. The way to remove the ambiguity differs: ${1}0 in .NET, the two-digit $010 or a named group with $<amount>0 in JavaScript. sed knows only \1-\9, so \10 always means "group 1 and 0" there.

Task.NET and Visual StudioJavaScriptsed (GNU)VS Code
Group 1$1$1\1$1
Named group${name}$<name>none-
Whole match$0$&&-
Group 1 followed by 0${1}0$010\10-
Literal dollar$$$$$-
Upper/lower caseMatchEvaluatorcallback\U \L \u \l \E\U \L \u \l

Visual Studio's find and replace uses .NET regex directly, so it shares the .NET column. In the Visual Studio Code column I only listed what its documentation mentions: groups with $n and case conversion with \u, \U, \l, \L. We use these four modifiers in the sixth post, together with repo-wide bulk changes in the IDE.

Bookstore: "Herbert, Frank" → "Frank Herbert"

In a catalog file Bookstore receives from a publisher, author names are written in library order, surname first. The storefront should show the first name first:

$ cat data/authors.csv
isbn,title,author
9780441172719,Dune,"Herbert, Frank"
9780141439587,Emma,"Austen, Jane"
9780547928210,"Fellowship of the Ring, The","Tolkien, J. R. R."
9780141439549,Middlemarch,George Eliot

The file has a trap: the book title is also in library order and contains a comma. The first attempt converts "every quoted field with a comma", the second only the field at the end of the line:

using System.Text.RegularExpressions;

string[] lines = File.ReadAllLines("data/authors.csv");

// first attempt: any quoted "Last, First" pair
Regex anyPair = new(@"""([^"",]+), ([^""]+)""");
// only the last column: the quoted field must end the line
Regex author = new(@"""(?<last>[^"",]+), (?<first>[^""]+)""$");

foreach (string line in lines)
    Console.WriteLine(anyPair.Replace(line, "\"$2 $1\""));
Console.WriteLine();

foreach (string line in lines)
    Console.WriteLine(author.Replace(line, "\"${first} ${last}\""));
Console.WriteLine();

// a MatchEvaluator when the new text needs code
foreach (string line in lines)
    Console.WriteLine(author.Replace(line, m => $"\"{m.Groups["last"].Value.ToUpperInvariant()}, {m.Groups["first"].Value}\""));
$ dotnet run dotnet/authors.cs
isbn,title,author
9780441172719,Dune,"Frank Herbert"
9780141439587,Emma,"Jane Austen"
9780547928210,"The Fellowship of the Ring","J. R. R. Tolkien"
9780141439549,Middlemarch,George Eliot

isbn,title,author
9780441172719,Dune,"Frank Herbert"
9780141439587,Emma,"Jane Austen"
9780547928210,"Fellowship of the Ring, The","J. R. R. Tolkien"
9780141439549,Middlemarch,George Eliot

isbn,title,author
9780441172719,Dune,"HERBERT, Frank"
9780141439587,Emma,"AUSTEN, Jane"
9780547928210,"Fellowship of the Ring, The","TOLKIEN, J. R. R."
9780141439549,Middlemarch,George Eliot

In the first attempt the book title was converted too; the result looks readable, but we changed a column we did not want to touch. Anchoring the field to the end of the line with $ changed only the author column. Named groups make the replacement more readable than "$2 $1" and do not shift when a group is added. The third block upper-cased the surname with a MatchEvaluator; the replacement-string language cannot do that, it takes code. George Eliot has no quotes and no comma, so no pattern touched it.

In JavaScript a named group is written as $<name> and a callback receives the groups in its last argument:

import { readFileSync } from "node:fs";

const csv = readFileSync("data/authors.csv", "utf8");
const author = /"(?<last>[^",]+), (?<first>[^"]+)"$/gm;

console.log(csv.replace(author, '"$<first> $<last>"'));
console.log(csv.replace(author, (...args) => {
  const { last, first } = args.at(-1);
  return `"${last.toUpperCase()}, ${first}"`;
}));

On the command line GNU sed can upper-case inside the replacement with \U:

$ cat shell/authors.sh
#!/usr/bin/env bash
# "Herbert, Frank" -> "Frank Herbert" (last column only)
sed -E 's/"([^",]+), ([^"]+)"$/"\2 \1"/' data/authors.csv
echo
# GNU sed: \U upper-cases until \E
sed -E 's/"([^",]+), ([^"]+)"$/"\U\1\E, \2"/' data/authors.csv
$ bash shell/authors.sh
isbn,title,author
9780441172719,Dune,"Frank Herbert"
9780141439587,Emma,"Jane Austen"
9780547928210,"Fellowship of the Ring, The","J. R. R. Tolkien"
9780141439549,Middlemarch,George Eliot

isbn,title,author
9780441172719,Dune,"HERBERT, Frank"
9780141439587,Emma,"AUSTEN, Jane"
9780547928210,"Fellowship of the Ring, The","TOLKIEN, J. R. R."
9780141439549,Middlemarch,George Eliot

This is enough for a one-off data cleanup. If the file arrives regularly and can contain quoted fields with line breaks, a CSV parser is the safer choice; the CSV post of the I/O in .NET series shows how.

In Production

If the replacement string comes from a user, it needs escaping too. In Bookstore's admin panel a store employee writes campaign text, and the code puts it in place of {discount} in a template:

using System.Text.RegularExpressions;

string banner = "Dune: {discount}";
string discount = "$0.99 off";   // text typed by a store admin

Console.WriteLine(Regex.Replace(banner, @"\{discount\}", discount));
Console.WriteLine(Regex.Replace(banner, @"\{discount\}", discount.Replace("$", "$$")));
Console.WriteLine(Regex.Replace(banner, @"\{discount\}", _ => discount));
$ dotnet run dotnet/dollar.cs
Dune: {discount}.99 off
Dune: $0.99 off
Dune: $0.99 off
$ node js/dollar.mjs
Dune: {discount} off
Dune: $& off
Dune: $& off
Dune: $0.99 off

.NET read $0 as "the whole match" and wrote {discount} back. The JavaScript file shows the same problem with $&; its last line also shows that $0 is not special in JavaScript. There are two safe options: turn dollar signs into $$, or pass a MatchEvaluator or callback that always returns the fixed text instead of a replacement string. If you do not need regex at all, string.Replace is the simplest.

Watch line endings in patterns that depend on line structure. Had we read a CRLF file on Windows, where every line ends with \r\n, as one string with File.ReadAllText and searched for "$ with RegexOptions.Multiline, .NET would not have found a single line, because .NET's $ matches only before \n, not before \r. JavaScript's $ with the m flag treats \r as a line end as well. That is why the .NET example reads the file line by line with ReadAllLines; the details come in the sixth post.

Do not scatter group numbers through your code. A line that reads m.Groups[3] silently reads the wrong field on the day a group is added to the pattern; m.Groups["status"] does not.

Common Pitfalls

Mixing named and unnamed groups

In .NET named groups are numbered last, in JavaScript and PCRE2 in order. The same pattern produces a different $1 on each side. Name all of them or turn off the unnamed ones with (?n).

Shifting numbers in nested groups

When you add a group such as (https?):// at the start of the pattern, the old $1 is now $2. Use (?:...) when you only need grouping.

Forgetting the dollar sign in replacements

Prices, campaign text or any text from a user can contain $. Turn it into $$ or use an evaluator or callback.

The $10 ambiguity

It stays as text in .NET and becomes "group 1 and 0" in JavaScript. Write ${1}0 in .NET and $010 or a named group in JavaScript.

The scope of alternation

^a|b$ is two separate patterns. When you use anchors and alternation together, limit the alternation with a group.

A Small Experiment

★☆☆ Write the ISBNs in data/authors.csv with hyphens, like 978-0-441-17271-9. Real hyphenation depends on the ISBN agency's range table because the publisher part varies in length; in this exercise use only Dune's 3-1-3-5-1 layout, with named groups in .NET and \1-\5 in sed.

★★☆ Add Austen's Pride and Prejudice (9780141439518) under Emma in the file. Find consecutive lines by the same author with a backreference. Hint: give the file to grep as a single record with -z and write the line break into the pattern.

★★★ Change the pattern (?<year>\d{4})-(\d{2}) and the replacement $2/$1 so that both .NET and JavaScript produce 11/2026, using a single pattern and a single replacement string.

In this post we saw how groups are numbered, how named groups are written in each flavor and how replacement syntax changes from tool to tool. In the next post we move on to constructs that check without consuming characters: lookahead and lookbehind, inline flags, JavaScript's v flag, Unicode properties such as \p{L} and case-insensitive search with the Turkish İ/ı.