C# & .NET (C# 14, .NET 10 LTS) Module 4 – Strings and text
Regular expressions with source generation
Match and parse text with .NET regular expressions: named groups, [GeneratedRegex] code made at build time, and patterns that cannot take exponential time.
What you will learn
- Write .NET regular expressions with groups and named captures, and read the parts of a match
- Generate a regex at build time with [GeneratedRegex] on a partial property
- Recognise a pattern that can backtrack exponentially, and name the two ways to bound it
Before you start
What you’ll learn · Before you start
What you will learn
- Write .NET regular expressions with groups and named captures, and read the parts of a match
- Generate a regex at build time with [GeneratedRegex] on a partial property
- Recognise a pattern that can backtrack exponentially, and name the two ways to bound it
Before you start
On this page
The log line 18:30:05 WARN [payments] retry 3 of 5 has four parts, and one regular expression with named groups
pulls all four out in a single match. Written with [GeneratedRegex], the same pattern becomes C# code when the
program is built, while a carelessly written pattern can keep the engine busy for hours on a short input.
You will write .NET regular expressions with groups and named captures, generate them at build time, and recognise the patterns whose running time can explode, with the two ways .NET offers to bound it.
Named groups pull the parts out of a match
A regular expression: A pattern that describes text, such as \d{2}:\d{2} for a time: .NET's Regex class finds, checks, splits or replaces the parts of a string that match it. is a small language for describing text: \d is a digit,
[A-Z] a capital letter, + one or more of what comes before it, {2} exactly two, ^ the start of the text and
$ its end or the point just before a final line break. Parentheses make a group, and (?<name>…) a
named group: A part of a regular expression written (?<name>…): the text it matches is kept in the match under that name, as Match.Groups["name"].Value., whose text the Match keeps under that name:
using System.Text.RegularExpressions;
// Three log lines, and one pattern whose named groups pull out the parts.
string[] lines =
[
"18:30:05 WARN [payments] retry 3 of 5",
"18:30:07 INFO [orders] order A-17 placed",
"not a log line at all",
];
var pattern = new Regex(@"^(?<time>\d{2}:\d{2}:\d{2}) (?<level>[A-Z]+) +\[(?<area>[a-z]+)\] (?<message>.+)$");
foreach (string line in lines)
{
Match m = pattern.Match(line);
if (!m.Success)
{
Console.WriteLine($"no match: \"{line}\"");
continue;
}
Console.WriteLine($"time {m.Groups["time"].Value} level {m.Groups["level"].Value,-5} area {m.Groups["area"].Value,-9} message \"{m.Groups["message"].Value}\"");
}
// Every number in a text, with where each one starts.
foreach (Match n in Regex.Matches("retry 3 of 5, waited 250 ms", @"\d+"))
{
Console.WriteLine($"number {n.Value} at index {n.Index}");
}
// Replace with a group: put the area first.
Console.WriteLine(pattern.Replace(lines[0], "${area}: ${message} (${level})")); Output
time 18:30:05 level WARN area payments message "retry 3 of 5" time 18:30:07 level INFO area orders message "order A-17 placed" no match: "not a log line at all" number 3 at index 6 number 5 at index 11 number 250 at index 21 payments: retry 3 of 5 (WARN)
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run logs.cs
Step through how the pattern consumed the first line:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Anchor and time. ^ pins the match to the start of the text. Then \d{2}:\d{2}:\d{2} needs two digits, a colon, two digits, a colon and two digits, and the group named time keeps them.
- Level. After one space, [A-Z]+ takes one or more capital letters as the group level. It stops at the first space, because a space is not a capital letter.
- Area. A space and a + allow the two spaces that line the levels up. \[ and \] are the brackets themselves, escaped, and [a-z]+ inside them is the group area.
- Message. After one more space, .+ takes everything up to the end of the line, and $ checks that the text ends there. The group message holds the rest.
- The result. m.Success is true and m.Groups["level"].Value is WARN. The third line, not a log line at all, failed at the first digit, so Success was false.
m.Groups["level"].Value gave WARN without counting positions, and the line that is not a log line came back with
Success false instead of an exception. Regex.Matches found every number in a sentence with its index, and
Replace reused the groups by name with ${area}. Brackets, dots and the other characters that mean something in a
pattern are escaped with a backslash when you mean the character itself, as \[ and \] were.
Checkpoint
Check: Groups and named captures
[GeneratedRegex] turns the pattern into code at build time
new Regex(pattern) parses the pattern when the program runs and then interprets it. Since .NET 7, the
source generator: A component that runs inside the C# compiler and adds code to the program while it is built. The regex source generator turns each [GeneratedRegex] pattern into C# matching code. for regular expressions does that work while the program is
built: mark a partial method, or since .NET 9 and C# 13 a partial property, with [GeneratedRegex(pattern)], and the
build writes a class with C# code that matches exactly that pattern:
using System.Text.RegularExpressions;
// The same pattern as logs.cs, generated at build time by the regex source generator.
string line = "18:30:05 WARN [payments] retry 3 of 5";
Match m = Patterns.LogLine.Match(line);
Console.WriteLine($"level {m.Groups["level"].Value}, area {m.Groups["area"].Value}");
Console.WriteLine($"IsMatch on a span, no string made: {Patterns.LogLine.IsMatch(line.AsSpan())}");
Console.WriteLine($"The generated matcher is a class of its own, derived from {Patterns.LogLine.GetType().BaseType?.Name}");
static partial class Patterns
{
// C# 13 and later: a partial property; a partial method returning Regex works too.
[GeneratedRegex(@"^(?<time>\d{2}:\d{2}:\d{2}) (?<level>[A-Z]+) +\[(?<area>[a-z]+)\] (?<message>.+)$")]
public static partial Regex LogLine { get; }
} Output
level WARN, area payments IsMatch on a span, no string made: True The generated matcher is a class of its own, derived from Regex
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run generated.cs
The generated matcher found the same groups, and IsMatch on a span of the line matched without making a new string.
The property returns one cached instance of a class derived from Regex, so there is nothing to store in a field.
Microsoft’s guide to the generator lists what it buys: no parsing at start-up, the speed of RegexOptions.Compiled
without compiling at run time, code that works with Native AOT and trimming, and generated code you can read and
step through. The analyzer SYSLIB1045 points at Regex calls that could be converted.
| Engine | Pattern prepared | Speed of matching | Native AOT |
|---|---|---|---|
new Regex(p) |
parsed at run time, interpreted | the slowest | works |
RegexOptions.Compiled |
parsed and compiled at run time | fast | ignored, so the pattern is interpreted |
[GeneratedRegex(p)] |
turned into C# when you build | fast, and no start-up cost | works |
Checkpoint
Check: [GeneratedRegex] at build time
Some patterns take exponential time on inputs that almost match
.NET’s default engines backtracking: How .NET's default regex engines search: when part of a pattern fails, they go back and try another way to match the text before it. Nested quantifiers can make the number of tries exponential.: when part of a pattern fails, they go back and try
another way to match what came before. For most patterns that costs little, but a quantifier inside a quantifier,
such as (a+)+$, lets a run of a be split into groups in exponentially many ways, and an input that almost
matches makes the engine try them all. Step through ^(a+)+$ on aaa!, three letters and a char it never accepts:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- One group. The group a+ takes all three a, and the + around it finds no second group. $ then needs the end of the text, but ! comes next, so this way fails.
- Give one back. The engine goes back: the first group gives up its last a, and a second group takes it. ! still comes next, so $ fails again.
- The other split. It goes back further: the first group keeps one a and the second takes the other two. The text still goes on with !, so this split fails too.
- Every a apart. The last way puts each a in a group of its own. It fails like the others, and no way to split three a is left.
- Then no match. Only now does the engine report no match, after all 4 ways to split 3 letters. Four letters can be split 8 ways, and every extra a doubles the count.
The engine tried every split before it gave up: 4 for three letters, 8 for four, and each extra a doubles the
count, so a run of 20 has 524,288. Microsoft’s guide calls the result catastrophic backtracking.
Two settings bound the damage. A match timeout makes a match that runs too long throw RegexMatchTimeoutException.
RegexOptions.NonBacktracking runs in time linear in the length of the input, but it supports no backreferences and
no lookarounds. Often the pattern can simply be rewritten without the nesting: ^a+$ matches the same texts as
^(a+)+$ and has nothing to try twice.
Key idea
Name groups for the parts you want back, generate patterns that are known when you build, and never let a quantifier repeat a group that can itself match the same text in several ways.
Checkpoint
Check: Patterns that backtrack exponentially
Catastrophic backtracking, reproduced and bounded Pro: opens with a Pro, Premium or Ultimate pass
A pattern with a quantifier inside a quantifier can take time that doubles with every extra character of input. The run below measures it on inputs from 15 to 23 chars, stops it with a time limit, and runs the same pattern with the engine that cannot backtrack.
In this part:
- Doubling time on inputs that almost match
- Two ways to bound it, and the better fix
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Validating input with an allowlist pattern, with its traps Pro: opens with a Pro, Premium or Ultimate pass
A pattern that checks the shape of input is only as strict as its anchors, its character classes and its options. The run below checks eight inputs against a first attempt and a fixed version, and three of them pass the first attempt although no clerk would accept them.
In this part:
- A simplified registration number, checked two ways
- Why each fix works
1 runnable example (C#) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
SYSLIB1045 and the code the generator writes Pro: opens with a Pro, Premium or Ultimate pass
The regex source generator also ships an analyzer that finds patterns worth converting, and it writes readable C# with comments that explain the pattern. The session below turns the analyzer’s suggestion into a build warning, fixes it, and prints the comments the generator wrote.
In this part:
- The suggestion as a warning, and the fix
- What the generated code says
3 runnable examples (C#, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: parse a log line with a generated pattern
Add a request id to the log pattern
Copy generated.cs, add a request id such as req=7f3a to the end of the log line, and extend the pattern with a
named group for it, req=(?<req>[0-9a-f]+). Print m.Groups["req"].Value and check that the old lines without an
id now fail to match, then make the group optional with (?: req=(?<req>[0-9a-f]+))?.
These questions go back over the log pattern, the generated property and the nested quantifier. Your least certain idea is asked first, and each choice explains what it gets right or misses:
Practice that adapts to you
Practise this lesson
8 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What does a named group give you in a .NET regular expression?
A name for a part of the match. (?<level>[A-Z]+) captures what that part matched and keeps it under the name, so code
reads m.Groups["level"].Value instead of counting parentheses to find group 2. The name keeps working when someone
adds a group earlier in the pattern, which would shift every number, and it can be reused in a replacement as
${level}. Check m.Success before reading groups: a failed match has empty groups rather than an exception. A
pattern with many groups reads better with names, and RegexOptions.ExplicitCapture makes the unnamed parentheses
plain groupings that capture nothing.
-
Mid to senior
After a release, one API endpoint now and then pins a CPU core for seconds. Profiles point at a Regex.IsMatch call that validates a free-text field with a new pattern. How do you confirm the cause, contain it in production and fix it properly?
-
Fresher to mid
A validator uses Regex.IsMatch(input, @"^\d{6}$") for a six-digit postal code. A tester finds that "411014" followed by a line break passes, and so does the same code written in Devanagari digits. Explain both and fix the pattern.
-
Mid to senior
A service creates about forty Regex objects with RegexOptions.Compiled in static fields, and its start-up is slow. The team wants to publish it with Native AOT. What would you change, how, and what would you check before and after?
Key takeaways
- A named group
(?<name>…)keeps its part of the text inMatch.Groups["name"], and a failed match returnsSuccessfalse rather than throwing. [GeneratedRegex]on a partial method or property turns a pattern into C# code at build time: no parsing at start-up, and code that works with Native AOT.- A quantifier inside a quantifier can make the backtracking engines take exponential time; a match timeout or
RegexOptions.NonBacktrackingbounds it, and a simpler pattern removes it.
Terms in this lesson
- backtracking
- How .NET's default regex engines search: when part of a pattern fails, they go back and try another way to match the text before it. Nested quantifiers can make the number of tries exponential.
- named group
- A part of a regular expression written (?<name>…): the text it matches is kept in the match under that name, as Match.Groups["name"].Value.
- regular expression
- A pattern that describes text, such as \d{2}:\d{2} for a time: .NET's Regex class finds, checks, splits or replaces the parts of a string that match it.
- source generator
- A component that runs inside the C# compiler and adds code to the program while it is built. The regex source generator turns each [GeneratedRegex] pattern into C# matching code.
References
- .NET regular expressions (Microsoft Learn)
- Regular expression language: quick reference (Microsoft Learn)
- .NET regular expression source generators (Microsoft Learn)
- Backtracking in regular expressions (Microsoft Learn)
- Best practices for regular expressions in .NET (Microsoft Learn)
- Regular expression options (NonBacktracking mode) (Microsoft Learn)
- SYSLIB1045: Convert to GeneratedRegexAttribute (Microsoft Learn)
- OWASP Input Validation Cheat Sheet (OWASP)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress