Regex Groups: Numbering, Named Groups, .NET Balancing
Regex groups tested in .NET, Python, PHP and Dart: grouping for quantifiers, how groups are numbered, named group syntax per engine, and .NET balancing groups.
Revised November 2026. This tutorial first appeared in Korean in 2021. We re-ran its five patterns with identical inputs in .NET 10 (System.Text.RegularExpressions), Python 3.9 and 3.12
re, PHP 8.5 (PCRE2 10.47) and Dart 3.11, whoseRegExpfollows JavaScript rules. The matches agreed in every engine. The final claim did not hold: the "valid IP address" pattern also matched56.1.1.1inside256.1.1.1,1.2.3.45inside1.2.3.456, and01.02.03.04. Group numbering, named groups, the per-engine syntax and the .NET balancing group are new.
This article is for anyone who uses parentheses in a regex and then has to read the result: which number a group gets, why a group holds only part of what it matched, and why a named group that worked in Python fails in C#. A subexpression (group) does two jobs. It groups part of a pattern so a quantifier or alternation applies to the whole thing, and it captures the text so you can read it later or refer back to it. Most surprises come from the second job.
The backreference syntax that refers to captured text (\1, \k<name>) is covered in our backreferences article, and zero-width groups such as (?=…) in lookaround.
Grouping: making a quantifier apply to more than one character
The 2021 article opened with this pair:
Text: abcc / abcabc (one per line)
abc{2} → abcc ({2} applies only to c)
(abc){2} → abcabc ({2} applies to the group)
All engines agreed. A quantifier applies to the single item just before it, and parentheses turn several items into one. The second example used the same idea to shorten an IP address pattern:
\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}
(\d{1,3}\.){3}\d{1,3}
Text: 192.168.0.1 / 127.0.0.1 / 123.456.789.101
Both patterns matched all three lines in every engine, including 123.456.789.101, which is not an address. That was the article's motivation for the longer pattern below. Note what group 1 contains after the shorter one: 0. for 192.168.0.1, 789. for the last line. A repeated group keeps only its last iteration. .NET keeps the history in Groups[1].Captures, which held 192., 168. and 0. in our run; Python's re, PHP and Dart have no equivalent. In Python, the third-party regex module (2026.11.9) returned ['192.', '168.', '0.'] from m.captures(1).
The 2021 IP pattern, re-checked and fixed
The 2021 pattern spells out the valid octet ranges with alternation:
(((\d{1,2})|(1\d{2})|(2[0-4]\d)|(25[0-5]))\.){3}((1[0-9]{2})|(2[0-4]\d)|(25[0-5])|(\d{1,2}))
On the article's own input it matched 192.168.0.1, 127.0.0.1 and 10.1.2.4 and skipped 123.456.789.101, as described. On other input it matched parts of invalid addresses:
| Input | Match (all engines) | Problem |
|---|---|---|
256.1.1.1 |
56.1.1.1 |
started inside the first octet |
1.2.3.456 |
1.2.3.45 |
stopped inside the last octet |
01.02.03.04 |
01.02.03.04 |
leading zeros accepted |
The pattern describes the octets correctly but says nothing about what may appear around the address. Without anchors the engine is free to start and stop anywhere. Two fixes, both tested on 192.168.0.1, 10.1.2.4, 256.1.1.1, 1.2.3.456, 01.02.03.04 and 1.2.3.4.5:
Search in text: (?<![\d.])(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}(?![\d.])
Validate a field: ^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$
Result (both, all engines): 192.168.0.1, 10.1.2.4
The octet [1-9]?\d rejects leading zeros, the lookarounds stop a match from starting or ending in the middle of a number, and every inner group is non-capturing, because we only want the whole address. For real input validation a library is often better, but check what it accepts. Python's ipaddress.ip_address rejected all four bad inputs. .NET's IPAddress.TryParse rejected 256.1.1.1, 1.2.3.456 and 1.2.3.4.5, but accepted 01.02.03.04 as 1.2.3.4 and even 10.1 as 10.0.0.1.
How groups are numbered
The rule is simple: count opening parentheses from left to right. Group 0 is the whole match.
Pattern: ((a)(b(c))) Text: abc
Group 1 = abc, 2 = a, 3 = bc, 4 = c (all engines)
The 2021 IP pattern therefore has 11 groups, and reading them shows two more rules. On 192.168.0.1:
| Group | .NET / Python / PHP | Dart (JS rules) |
|---|---|---|
1 ((…)\.) |
0. |
0. |
| 2 (octet) | 0 |
0 |
3 (\d{1,2}) |
0 |
0 |
4 (1\d{2}) |
168 |
null |
| 7 (last octet) | 1 |
1 |
11 (\d{1,2}) |
1 |
1 |
Groups 5, 6, 8, 9 and 10 did not participate and were null everywhere. Group 4 is the interesting one. In the third iteration of {3} the octet 0 was matched by alternative 3, but .NET, Python and PHP still reported group 4 as 168 from the second iteration. JavaScript rules reset the groups inside a quantified group at each iteration, so Dart reported null. If you read inner groups of a repeated group, the result depends on the engine.
Alternation does not reuse numbers. (a)|(b) on ab gave a with group 2 null, then b with group 1 null, in every engine. PCRE has a branch reset group that shares numbers between alternatives, (?|(a)|(b)), which gave group 1 = a and then group 1 = b in PHP. Every other engine rejected it:
.NET : Invalid pattern '(?|(a)|(b))' at offset 3. Unrecognized grouping construct.
Python : unknown extension ?| at position 1
Dart : FormatException: Invalid group (?|(a)|(b))
Non-capturing groups
When you need grouping but not capturing, write (?:…). It works in every engine and keeps the numbering stable: (?:ab)+(c) on ababc matched ababc with group 1 = c. In longer patterns, add ?: to every group you never read, so that adding a group later does not shift the numbers in your code.
.NET has a switch for this: RegexOptions.ExplicitCapture, or (?n) inline, makes plain ( ) non-capturing and keeps only named groups. With (\d{4})-(?<m>\d{2}) on 2026-11, the match had 2 groups (0 and m) instead of 3. No other engine we tested has the option.
Named groups: same idea, three spellings
A named group documents what it captures and survives refactoring better than a number. The syntax is where engines disagree. With the pattern for 2026-11:
| Pattern | .NET | Python 3.9 / 3.12 | PHP | Dart |
|---|---|---|---|---|
(?<y>\d{4})-(\d{2}) |
match | error unknown extension ?<y |
match | match |
(?P<y>\d{4})-(\d{2}) |
error Unrecognized grouping construct |
match | match | error Invalid group |
(?'y'\d{4})-(\d{2}) |
match | error | match | error |
The bigger surprise is the numbering. Python, PHP and Dart number named and unnamed groups together from left to right: group 1 = 2026, group 2 = 11. .NET numbers unnamed groups first and named groups after them: group 1 = 11, group 2 = 2026. So the "same" replacement gives different results:
Pattern (?<y>\d{4})-(\d{2}) on 2026-11, replacement $2/$1
.NET → 2026/11
PHP → 11/2026
Python → 11/2026 (pattern with (?P<y>…), replacement \2/\1)
Dart → 11/2026 (replaceAllMapped with m[2] and m[1])
The safe habit is to refer to named groups by name (${y} in .NET, \g<y> in Python, m.namedGroup('y') in Dart), and not to mix named and numbered references in one pattern.
Reusing a name is also engine-specific. (?<d>\d+)-x|(?<d>[a-z]+)-y on abc-y worked in .NET (one group d = abc). PHP refused it with two named subpatterns have the same name (PCRE2_DUPNAMES not set) until we added (?J); Python (with (?P<d>…)) failed with redefinition of group name 'd' as group 2; was group 1; Dart failed with Duplicate capture group name.
Advanced: the .NET balancing group
.NET has a group type no other engine here supports. (?<name>…) pushes a capture onto a stack, (?<-name>…) pops one, and (?<new-old>…) pops old and captures the text between it and the current position as new. Together with the conditional (?(name)(?!)), which fails if the stack is not empty, a regex can check nesting:
var p = @"^[^()]*(?:(?:(?<open>\()[^()]*)+(?:(?<inner-open>\))[^()]*)+)*(?(open)(?!))$";
foreach (var s in new[] { "f(a(b)c)", "x(1)+(2(3))", "f(a(b)c", "f(a)b)c(" }) {
var m = Regex.Match(s, p);
Console.WriteLine($"{s,-12} {(m.Success ? "match" : "no match")} inner=[{string.Join(", ", m.Groups["inner"].Captures.Select(c => c.Value))}]");
}
Output on .NET 10:
f(a(b)c) match inner=[b, a(b)c]
x(1)+(2(3)) match inner=[1, 3, 2(3)]
f(a(b)c no match inner=[]
f(a)b)c( no match inner=[]
inner collects the content of every balanced pair, innermost first. To find balanced groups inside longer text, \((?>[^()]+|\((?<d>)|\)(?<-d>))*(?(d)(?!))\) on f(a(b)c) + g(x) + h(y returned (a(b)c) and (x) and skipped the unclosed (y.
Other engines rejected the syntax: Python with unknown extension ?<o, PHP with subpattern name expected, Dart with Invalid capture group name. PCRE solves the same problem with recursion: in PHP, \((?:[^()]++|(?R))*\) returned (a(b)c) and (x) for the same text, as did Python's regex module; Python re failed with unknown extension ?R. Balancing groups also rule out .NET's linear-time engine: RegexOptions.NonBacktracking is not supported in conjunction with expressions containing: 'captured group conditional …'. For anything deeper than a quick check, a small parser is easier to maintain, but for validating a config value or a template placeholder, one regex is often enough.
Cheat sheet and how we tested
| Task | Write |
|---|---|
| Repeat several characters | (abc){2} |
| Group without capturing | (?:…) |
| Named group | .NET, Dart: (?<n>…); Python: (?P<n>…); PHP: either |
| Every iteration of a repeated group | .NET Groups[i].Captures; Python regex captures() |
| Share numbers across alternatives | PHP (?|…) only |
| Check nested parentheses | .NET balancing group; PHP (?R) |
| Whole IPv4 address | anchored octet pattern above, or a parser you have tested |
Engines: .NET 10.0.14 (Regex.Matches, Regex.Replace), Python 3.9.6 and 3.12.14 (re.finditer, re.sub), PHP 8.5.6 with PCRE2 10.47 (preg_match_all with PREG_UNMATCHED_AS_NULL, preg_replace), and Dart 3.11.5 (RegExp.allMatches). Node.js was not installed, so JavaScript rules were checked through Dart. Patterns and inputs came from one JSON file, so every engine read the same bytes.
Tested on macOS on an Apple Silicon Mac, November 2026.
FAQ
Why is group 1 only "0." after matching 192.168.0.1 with (\d{1,3}\.){3}\d{1,3}?
A repeated group keeps only its last iteration. Every engine returned "0." for group 1. .NET also keeps the earlier iterations in Groups[1].Captures (192., 168., 0.), and Python can do the same with the third-party regex module through captures(1).
How are named groups numbered in .NET?
After all unnamed groups. With (?<y>\d{4})-(\d{2}) on 2026-11, .NET gave group 1 = 11 and group 2 = 2026, so the replacement $2/$1 produced 2026/11. Python, PHP and Dart number groups left to right and produced 11/2026 with the same numbers.
Can I write (?<name>...) in Python?
Not in the re module. Python 3.9 and 3.12 both failed with "unknown extension ?<y at position 1". Python needs (?P<name>...). PHP accepts both spellings, and .NET and Dart accept only (?<name>...).