The Regex Source Generator Explained
Regular expressions in .NET have always had a performance trade-off. You could interpret them at runtime (slow on every call), compile them with RegexOptions.Compiled (slow first call, fast thereafter), or use Regex.CompileToAssembly (fast, but awkward to use). .NET 7 introduced a fourth option: source-generated regex, which compiles the pattern at build time into optimised C# code.
The Old Ways
Before the source generator, the standard approaches were:
// Interpreted — parsed and matched at runtime every time
var regex1 = new Regex(@"\d{3}-\d{4}");
// Compiled — JIT compiles the regex on first use
var regex2 = new Regex(@"\d{3}-\d{4}", RegexOptions.Compiled);
// Cached static — Regex.IsMatch caches the last 15 patterns
bool match = Regex.IsMatch(input, @"\d{3}-\d{4}");
RegexOptions.Compiled generates IL at runtime using Reflection.Emit. This is fast once warmed up, but the initial compilation is expensive, and the generated IL cannot be saved or inspected.
The Source Generator Approach
With the [GeneratedRegex] attribute, you declare a partial method and the source generator fills it in:
using System.Text.RegularExpressions;
public partial class PhoneValidator
{
[GeneratedRegex(@"\d{3}-\d{4}")]
private static partial Regex PhonePattern();
public bool IsValid(string input) => PhonePattern().IsMatch(input);
}
The method must be static, partial, parameterless, and return Regex. The class must also be partial.
What Gets Generated
The source generator produces a nested class with a custom Regex subclass. Instead of interpreting a regex pattern or emitting IL at runtime, it generates C# code that directly implements the matching logic using spans, character comparisons, and jumps.
For the pattern \d{3}-\d{4}, the generated code looks something like this (simplified):
// Auto-generated — do not modify
private sealed class PhonePatternImpl : Regex
{
protected override bool TryFindNextPossibleStartingPosition(
ReadOnlySpan<char> inputSpan, ref int pos)
{
// Scan for a digit to start matching
int i = inputSpan.Slice(pos).IndexOfAnyInRange('0', '9');
if (i < 0) return false;
pos += i;
return true;
}
protected override bool TryMatchAtCurrentPosition(
ReadOnlySpan<char> inputSpan, ref int pos)
{
// Match exactly 3 digits, a hyphen, then 4 digits
if (pos + 8 > inputSpan.Length) return false;
if (!char.IsDigit(inputSpan[pos])) return false;
if (!char.IsDigit(inputSpan[pos + 1])) return false;
if (!char.IsDigit(inputSpan[pos + 2])) return false;
if (inputSpan[pos + 3] != '-') return false;
if (!char.IsDigit(inputSpan[pos + 4])) return false;
if (!char.IsDigit(inputSpan[pos + 5])) return false;
if (!char.IsDigit(inputSpan[pos + 6])) return false;
if (!char.IsDigit(inputSpan[pos + 7])) return false;
pos += 8;
return true;
}
}
This is far more efficient than the interpreted engine, and it avoids the startup cost of runtime compilation. The generated code also uses ReadOnlySpan<char>, which means zero allocations for matching operations.
Passing Options
You can pass RegexOptions and a timeout:
[GeneratedRegex(
@"^[a-z]+@[a-z]+\.[a-z]{2,}$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex EmailPattern();
[GeneratedRegex(@"\b\w+\b", RegexOptions.None, matchTimeoutMilliseconds: 1000)]
private static partial Regex WordPattern();
Note that not all RegexOptions are compatible. RegexOptions.Compiled is ignored (the whole point is compile-time generation), but all others like IgnoreCase, Multiline, Singleline, and CultureInvariant are supported.
IDE Integration
Because the generator runs in the IDE, you get immediate feedback. If your pattern has a syntax error, you see a diagnostic at the [GeneratedRegex] attribute — not a runtime ArgumentException:
// This produces a compile-time error: "Invalid pattern"
[GeneratedRegex(@"[unclosed")]
private static partial Regex BadPattern();
You can also inspect the generated code using the Syntax Visualiser or by enabling EmitCompilerGeneratedFiles in your project.
Performance
Benchmarks from the .NET team show significant improvements:
| Approach | First Call | Subsequent Calls | Memory |
|---|---|---|---|
| Interpreted | Fast | Slowest | Low |
| Compiled | Slowest | Fast | High |
| Source Generated | Instant | Fastest | Lowest |
The source-generated version wins on every axis. It has no first-call penalty because the code is already compiled. It matches faster because the generated code is specialised for the specific pattern. And it allocates less because it uses span-based APIs.
When to Use It
Use [GeneratedRegex] whenever you have a regex pattern known at compile time. There is essentially no downside compared to RegexOptions.Compiled.
You cannot use it when:
- The pattern is determined at runtime (user input, configuration files).
- You are targeting a framework older than .NET 7.
For dynamic patterns, continue using the Regex constructor. For everything else, the source generator is the right choice.
Summary
The regex source generator is a textbook example of what source generators excel at: taking work that was previously done at runtime and moving it to compile time. The result is faster startup, better throughput, lower memory usage, and compile-time validation of your patterns. If you are on .NET 7 or later and still using new Regex(...) with a constant pattern, switching to [GeneratedRegex] is a quick win.