Regex.Match, Matches, and IsMatch in C#: Named Groups and Capture Collections
Working with C# regex match operations -- IsMatch, Match, and Matches -- is the foundation of text extraction in .NET. But most tutorials stop at the basics. They show you match.Value and move on. The real power comes from named capturing groups, the full group and capture collection model, and the modern zero-allocation EnumerateMatches API introduced in .NET 7.
This article walks through all three methods in depth, shows you how to work with named groups and the GroupCollection, and covers the Span-based APIs that eliminate unnecessary heap allocations in high-throughput scenarios.
IsMatch -- The Simplest Check
Regex.IsMatch answers one question: does the pattern match anywhere in the input? It returns bool and is the fastest way to check for the presence of a pattern:
using System.Text.RegularExpressions;
var regex = new Regex(@"[A-Z][a-z]+");
Console.WriteLine(regex.IsMatch("Hello world")); // True
Console.WriteLine(regex.IsMatch("hello world")); // False
Console.WriteLine(regex.IsMatch("123 HELLO")); // False
You can also call it statically for one-off checks:
bool hasZipCode = Regex.IsMatch(address, @"d{5}(?:-d{4})?");
IsMatch with Position
The instance overload IsMatch(string, int) starts matching from a specific character index in the string. This is useful when you've already scanned up to a known position -- for example, after parsing a header and wanting to check only the body portion:
var regex = new Regex(@"d+");
// Start searching from index 5
bool found = regex.IsMatch("abc 12 xyz", 5);
Console.WriteLine(found); // True (finds "12" starting from index 4, within range)
IsMatch with ReadOnlySpan (.NET 7+)
In .NET 7+, there's a Span overload that avoids string allocation when you already have a ReadOnlySpan<char>:
ReadOnlySpan<char> span = "Hello 42 World".AsSpan(6, 2); // "42"
bool isDigits = Regex.IsMatch(span, @"^d+$");
Console.WriteLine(isDigits); // True
This is valuable when slicing strings from a larger buffer -- you avoid creating a substring just to test it.
Match -- Finding the First Occurrence
Regex.Match returns the first match in the input as a Match object. Always check match.Success before accessing any properties -- if no match is found, match.Success is false and match.Value returns an empty string (never null). This null-safe design means you can safely chain calls without null checks as long as you verify Success first:
var regex = new Regex(@"d{4}-d{2}-d{2}");
var match = regex.Match("Filed on 2026-05-08, reviewed 2026-06-01");
if (match.Success)
{
Console.WriteLine($"Found: {match.Value}"); // 2026-05-08
Console.WriteLine($"At index: {match.Index}"); // 9
Console.WriteLine($"Length: {match.Length}"); // 10
}
If there's no match, match.Success is false and match.Value is an empty string (Match.Empty).
Iterating with NextMatch
To step through matches one at a time without building a full MatchCollection, call NextMatch() on the previous Match. This is useful when you want early exit -- for example, you only care about the first three matches and want to stop. It's also slightly more memory-efficient for very large inputs where you don't need all matches loaded at once:
var regex = new Regex(@"[A-Z]{2,}");
var match = regex.Match("The NATO meeting in NYC covered USB and API standards");
while (match.Success)
{
Console.WriteLine(match.Value);
match = match.NextMatch();
}
// NATO
// NYC
// USB
// API
This is useful when you want to stop early or process matches lazily without creating all of them upfront.
Matches -- Getting All Matches at Once
Regex.Matches returns a MatchCollection containing all non-overlapping matches in the input. The collection is lazily evaluated -- matches are found on demand as you enumerate:
var regex = new Regex(@"[A-Z][a-z]+");
var matches = regex.Matches("London Paris Tokyo Berlin");
Console.WriteLine($"Count: {matches.Count}"); // 4
foreach (Match m in matches)
{
Console.WriteLine($" {m.Value} at {m.Index}");
}
Matches Returns a Lazy Collection
MatchCollection evaluates lazily. Accessing .Count forces full evaluation, but iterating with foreach only evaluates as needed. If you only need the first N matches, use Match + NextMatch instead.
Named Capturing Groups
Named groups transform regex from a positional API to a semantic one. Instead of match.Groups[1], you write match.Groups["year"]. This makes complex patterns maintainable.
The syntax is (?<name>pattern):
var regex = new Regex(@"(?<year>d{4})-(?<month>d{2})-(?<day>d{2})");
var match = regex.Match("Published: 2026-05-08");
if (match.Success)
{
var year = match.Groups["year"].Value; // "2026"
var month = match.Groups["month"].Value; // "05"
var day = match.Groups["day"].Value; // "08"
Console.WriteLine($"Date: {year}/{month}/{day}");
}
Named groups also appear in GroupCollection by name AND by index -- you can access them either way:
// Both of these work:
Console.WriteLine(match.Groups["year"].Value); // "2026"
Console.WriteLine(match.Groups[1].Value); // "2026" (same group)
Getting All Group Names
The Regex object exposes all its group names -- both named and numbered -- through GetGroupNames(). This enables dynamic processing where you don't know all group names at compile time, such as when building a generic log parser or a template engine that extracts named fields from arbitrary patterns:
var regex = new Regex(@"(?<first>w+)s+(?<last>w+)");
var match = regex.Match("John Doe");
foreach (string name in regex.GetGroupNames())
{
if (name == "0") continue; // Group 0 is always the full match
Console.WriteLine($"{name}: {match.Groups[name].Value}");
}
// first: John
// last: Doe
Numbered Capturing Groups
Without named groups, captures are numbered starting at 1 (group 0 is always the entire match):
var regex = new Regex(@"(d{3})-(d{3})-(d{4})");
var match = regex.Match("Call 800-555-1234 now");
if (match.Success)
{
Console.WriteLine(match.Groups[0].Value); // 800-555-1234 (full match)
Console.WriteLine(match.Groups[1].Value); // 800
Console.WriteLine(match.Groups[2].Value); // 555
Console.WriteLine(match.Groups[3].Value); // 1234
}
For maintainability, prefer named groups in any pattern with more than one group.
Non-Capturing Groups
When you need parentheses for grouping (to apply a quantifier or create an alternation) but don't need to capture the content, use (?:...). Non-capturing groups are faster than capturing groups because the engine doesn't allocate space to track the matched content in the GroupCollection. Use them by default and only switch to capturing groups when you need the captured value:
// Captures only the domain, not the protocol
var regex = new Regex(@"(?:https?|ftp)://(?<domain>[^/]+)");
var match = regex.Match("https://www.devleader.ca/articles");
Console.WriteLine(match.Groups["domain"].Value); // www.devleader.ca
Non-capturing groups are faster because the engine doesn't track their content.
GroupCollection -- All Groups in a Match
match.Groups is a GroupCollection that holds all capturing groups in the match. It supports both integer-index access (by group number) and string-key access (by group name). Group 0 is always the full match text. Named groups appear after numbered groups in the collection:
var regex = new Regex(@"(?<protocol>https?|ftp)://(?<host>[^/:]+)(?::(?<port>d+))?");
var match = regex.Match("https://api.example.com:8443/v1");
if (match.Success)
{
var groups = match.Groups;
Console.WriteLine($"Protocol: {groups["protocol"].Value}"); // https
Console.WriteLine($"Host: {groups["host"].Value}"); // api.example.com
Console.WriteLine($"Port: {groups["port"].Value}"); // 8443
// Check if an optional group participated in the match
Console.WriteLine($"Port matched: {groups["port"].Success}"); // True
}
Optional groups (wrapped in ?) may not participate in a match. Always check group.Success before using the value.
CaptureCollection -- Repeated Groups
When a group quantifier causes it to match multiple times, each individual match is stored in the CaptureCollection:
// Group captures each word individually
var regex = new Regex(@"(?<word>w+)+");
var match = regex.Match("one two three");
var wordGroup = match.Groups["word"];
Console.WriteLine($"Final value: {wordGroup.Value}"); // "three" (last capture)
foreach (Capture capture in wordGroup.Captures)
{
Console.WriteLine(capture.Value);
}
// one
// two
// three
group.Value and group.Index reflect the LAST capture -- use Captures to get all of them. This is a frequently misunderstood behavior.
Extracting Structured Data with Matches
Here's a practical example combining everything above -- extracting structured data from log lines:
var pattern = new Regex(
@"[(?<timestamp>d{4}-d{2}-d{2} d{2}:d{2}:d{2})] " +
@"[(?<level>DEBUG|INFO|WARN|ERROR)] " +
@"(?<message>.+)",
RegexOptions.Compiled);
string[] logLines =
[
"[2026-05-08 14:23:01] [INFO] Service started",
"[2026-05-08 14:23:05] [ERROR] Connection refused",
"[2026-05-08 14:23:07] [WARN] Retry attempt 1",
];
foreach (string line in logLines)
{
var match = pattern.Match(line);
if (!match.Success) continue;
var timestamp = match.Groups["timestamp"].Value;
var level = match.Groups["level"].Value;
var message = match.Groups["message"].Value;
Console.WriteLine($"[{level}] {timestamp}: {message}");
}
This kind of pattern -- named groups + RegexOptions.Compiled for repeated use -- is the idiomatic C# approach before .NET 7. For new code, use [GeneratedRegex] instead.
EnumerateMatches (.NET 7+) -- Zero-Allocation Iteration
Regex.EnumerateMatches is the performance upgrade for hot paths. It returns a ValueMatchEnumerator that yields ValueMatch structs -- no heap allocations for the match results themselves:
using System.Text.RegularExpressions;
var regex = new Regex(@"d+");
var input = "Values: 10 20 30 40 50 60 70 80 90 100";
// Zero allocation iteration
foreach (ValueMatch vm in regex.EnumerateMatches(input))
{
// vm.Index and vm.Length only -- no Value property
var slice = input.AsSpan(vm.Index, vm.Length);
int value = int.Parse(slice);
Console.Write($"{value} ");
}
// 10 20 30 40 50 60 70 80 90 100
Note that ValueMatch is a ref struct -- it only has Index and Length. You must slice the original input yourself to get the matched text. This is intentional: it forces zero-copy access patterns.
EnumerateMatches with ReadOnlySpan
The Span overload of EnumerateMatches accepts a ReadOnlySpan<char> directly, avoiding even the initial string allocation when you're working with memory-mapped files, ArrayPool<char> buffers, or stack-allocated text. This is the most allocation-efficient way to scan text in .NET 7+. The trade-off is that you can't use the string's indexer -- you must work entirely with the span:
ReadOnlySpan<char> inputSpan = "10 20 30".AsSpan();
var regex = new Regex(@"d+");
foreach (ValueMatch vm in regex.EnumerateMatches(inputSpan))
{
var numSpan = inputSpan.Slice(vm.Index, vm.Length);
Console.WriteLine(numSpan.ToString());
}
Using [GeneratedRegex] with Match Operations
For production code, pair [GeneratedRegex] with the match methods above for compile-time optimization:
using System.Text.RegularExpressions;
public partial class LogParser
{
[GeneratedRegex(
@"[(?<timestamp>d{4}-d{2}-d{2} d{2}:d{2}:d{2})] " +
@"[(?<level>DEBUG|INFO|WARN|ERROR)] " +
@"(?<message>.+)",
RegexOptions.None,
matchTimeoutMilliseconds: 1000)]
private static partial Regex LogLinePattern();
public LogEntry? Parse(string line)
{
var match = LogLinePattern().Match(line);
if (!match.Success) return null;
return new LogEntry(
match.Groups["timestamp"].Value,
match.Groups["level"].Value,
match.Groups["message"].Value);
}
}
public sealed record LogEntry(string Timestamp, string Level, string Message);
The partial class + partial method pattern is required. The source generator fills in the method body at compile time.
Designing Capture Groups for Maintainable Patterns
When you write patterns with named groups, the naming strategy matters as much as the pattern itself. Use names that reflect the semantic meaning of what's being captured, not the technical structure. A group named (?<year>d{4}) is infinitely more readable than (?<g1>d{4}) six months later when you return to the code.
Group ordering matters for readability and for code that accesses groups by index. Even when using named groups exclusively in your consuming code, the match.Groups collection is ordered by appearance in the pattern. If you're debugging a failing match in a log, you can cross-reference group index against pattern position. Keeping groups in a logical left-to-right order that mirrors the data structure makes this much easier.
Optional groups require extra care. Whenever a group is wrapped in ? or is inside an optional branch, you must check group.Success before reading group.Value. A common bug is accessing group.Value on a group that didn't participate in the match -- it returns an empty string rather than null, which can cause silent data corruption when empty string is a valid sentinel in your domain. Defensive patterns are: check group.Success first, or use a ternary (group.Success ? group.Value : null).
Named groups also support backreferences within the pattern itself. The pattern (?<quote>["']).*?k<quote> matches both single and double-quoted strings because the backreference k<quote> refers to whatever the quote group captured. This is powerful for matching symmetric delimiters where you need the opening and closing to match.
When the same group appears in both branches of an alternation -- like (?<year>d{4})|(?<year>[A-Z]{4}) -- the group is called a "shared group." Shared groups across alternation branches are supported in .NET regex and can simplify consuming code: instead of two separate named groups, one name captures either form.
For patterns that are complex enough to span multiple lines, use RegexOptions.IgnorePatternWhitespace together with inline comments (lines starting with #). This is especially useful for validation patterns where each sub-expression has a distinct responsibility that benefits from documentation:
[GeneratedRegex("""
^ # start of string
(?<area>d{3}) # 3-digit area code
[-.s]? # optional separator
(?<exchange>d{3}) # 3-digit exchange
[-.s]? # optional separator
(?<number>d{4}) # 4-digit subscriber number
$ # end of string
""",
RegexOptions.IgnorePatternWhitespace,
matchTimeoutMilliseconds: 200)]
private static partial Regex AnnotatedPhonePattern();
This self-documenting style pays dividends on patterns you maintain for years.
Using LINQ with Regex Matches
The Matches method returns a MatchCollection which implements IEnumerable<Match>. This means you can use LINQ directly over regex matches, combining the power of pattern matching with the expressiveness of LINQ queries.
using System.Linq;
using System.Text.RegularExpressions;
var regex = new Regex(@"d{5}"); // US ZIP codes
string[] addresses =
[
"123 Main St, New York, NY 10001",
"no zip here",
"456 Oak Ave, Boston, MA 02101",
"789 Pine Rd, Chicago, IL 60601",
];
// Extract all ZIP codes from all addresses
var allZips = addresses
.SelectMany(addr => regex.Matches(addr).Cast<Match>())
.Select(m => m.Value)
.Distinct()
.OrderBy(z => z)
.ToList();
The .Cast<Match>() call is necessary because MatchCollection predates generics and implements IEnumerable (non-generic). In .NET 7+ you can avoid this by using EnumerateMatches with a LINQ over spans, but that requires more care since ValueMatch is a ref struct.
For grouping results by a captured field, LINQ's GroupBy pairs naturally with named groups:
var logPattern = new Regex(@"[(?<level>INFO|WARN|ERROR)] (?<msg>.+)");
string[] lines = File.ReadAllLines("app.log");
var grouped = lines
.Select(line => logPattern.Match(line))
.Where(m => m.Success)
.GroupBy(m => m.Groups["level"].Value)
.ToDictionary(g => g.Key, g => g.Select(m => m.Groups["msg"].Value).ToList());
This gives you a dictionary keyed by log level, with lists of messages -- directly from raw log lines in a single LINQ expression. Using LINQ over Matches is a concise and readable pattern for report generation, data aggregation, and log analysis tasks.
Pattern Matching vs Regex: When to Use Which
C# has powerful built-in pattern matching via switch expressions and is patterns. These are great for structural matching on objects. Regex is for text pattern matching. They complement each other -- for example, you might use switch to dispatch on a validated string and regex to validate/extract the string first.
If you're exploring the switch side of pattern matching, C# Enum Switch: Pattern Matching and Exhaustive Checks covers exhaustive switch expressions that pair well with regex-validated enum parsing.
For building processing pipelines where each match triggers an event, consider combining regex with the Observer Design Pattern in C# -- subscribers handle different match categories without coupling the parsing logic to the handlers.
FAQ
What does match.Success mean in C#?
match.Success is true when the regex engine found a match. If you call Regex.Match on a string that doesn't match, you get a Match object where Success is false (not null). Always check this before accessing match.Value, match.Index, or any group.
How do I access named groups in C# regex?
Use match.Groups["groupName"].Value where groupName is the identifier inside (?<groupName>...). The group name is case-sensitive and must be a valid identifier.
What is the difference between Match and Matches in C#?
Match returns the first occurrence as a Match object. Matches returns all non-overlapping occurrences as a MatchCollection. For iterating all matches, Matches is often cleaner. For finding just the first -- or when you want to stop early -- use Match with NextMatch.
What is EnumerateMatches in .NET 7?
Regex.EnumerateMatches is a .NET 7+ API that returns matches as ValueMatch ref structs via an enumerator. Unlike Matches, it doesn't allocate a MatchCollection or individual Match objects. This makes it ideal for high-throughput text processing. The trade-off: ValueMatch only has Index and Length -- you must slice the source string yourself.
Can a capturing group fail to participate in a match?
Yes. Optional groups -- wrapped in ? or contained inside (?:...)? -- may not participate in a match even when the overall pattern matches. In that case, group.Success is false and group.Value is an empty string. Always check group.Success for optional groups.
What is GroupCollection[0] in a Regex match?
Group 0 is always the full match text -- the entire portion of the input string that the pattern matched. Groups 1, 2, etc. are the capturing groups (parentheses). Named groups are also accessible by index in the order they appear in the pattern.
How do I extract multiple values from repeated groups?
Use the Captures property: match.Groups["name"].Captures. Each Capture object in the collection represents one occurrence of the group. Note that group.Value and group.Index always reflect the LAST capture -- Captures gives you all of them in order.

