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

Regex in .NET: GeneratedRegex, RegexOptions, Timeouts, NonBacktracking and Span APIs

Mert Özen Nov 15, 2026 15 min 6 views
Regex in .NET: GeneratedRegex, RegexOptions, Timeouts, NonBacktracking and Span APIs

Use [GeneratedRegex] for constant patterns, create run-time patterns once and keep them, and give untrusted input a timeout or NonBacktracking; in our measurement new Regex(..., Compiled) on every call was thirty-three thousand times slower than the source generator. A BenchmarkDotNet measurement, allocation-free EnumerateMatches, timeouts and the constructs NonBacktracking rejects, when IgnoreCase reads the culture, and ISBN validation and Turkish slugs for Bookstore, with real output.

Version note: The examples in this post were built and run with .NET 10 (SDK 10.0.404, runtime 10.0.15) and BenchmarkDotNet 0.15.8. The C# examples run as .NET 10 file-based apps with the dotnet run file.cs command.

Guess First

In Bookstore, the online bookstore used as the shared example across this site's series, the catalog import validates hundreds of thousands of ISBNs every night. Emre wants to speed up the validation method and writes three versions:

// A: a new Regex with the Compiled option on every call
bool A(string isbn) => new Regex(Pattern, RegexOptions.Compiled).IsMatch(isbn);

// B: the static method, the pattern passed as a string on every call
bool B(string isbn) => Regex.IsMatch(isbn, Pattern);

// C: the Regex produced by the source generator
bool C(string isbn) => Isbn13.IsMatch(isbn);

Anyone who thinks "Compiled is the fastest" picks A. Rank the three versions from fastest to slowest for one ISBN. How big is the gap between A and C: 2, 20 or 2000 times?

Interpreter, Compiled and the Source Generator

When you create a Regex object in .NET, the pattern is parsed, turned into a tree, optimized and converted into a list of instructions that the interpreter runs. That is the default path: the interpreter. RegexOptions.Compiled adds IL specialized for that pattern on top of the same work; matching is faster, but construction is much more expensive, and the IL also has to be JIT-compiled on first use. The source generator introduced in .NET 7 produces the same code as C# at build time: no parsing or compilation at run time, the code is part of the project, and you can step into it with the debugger.

I measured the answer to the guess with BenchmarkDotNet. Each benchmark checks the same ISBN once. The first three start from the pattern on every call; the next four reuse a single instance created beforehand:

#:package BenchmarkDotNet@0.15.8
#:property Configuration=Release
#:property PublishAot=false

using System.Text.RegularExpressions;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<IsbnBenchmarks>(args: args);

[MemoryDiagnoser]
[InProcess]
public partial class IsbnBenchmarks
{
    private const string Pattern = @"^97[89]-[0-9]{1,5}-[0-9]{1,7}-[0-9]{1,7}-[0-9]\z";
    private const string Input = "978-0-441-17271-9";

    private static readonly Regex Interpreted = new(Pattern);
    private static readonly Regex Compiled = new(Pattern, RegexOptions.Compiled);
    private static readonly Regex NonBacktracking = new(Pattern, RegexOptions.NonBacktracking);

    [GeneratedRegex(Pattern)]
    private static partial Regex Generated { get; }

    // construction on every call
    [Benchmark] public bool NewInterpreted() => new Regex(Pattern).IsMatch(Input);
    [Benchmark] public bool NewCompiled() => new Regex(Pattern, RegexOptions.Compiled).IsMatch(Input);
    [Benchmark] public bool StaticMethod() => Regex.IsMatch(Input, Pattern);

    // one instance, reused
    [Benchmark(Baseline = true)] public bool Interpreter() => Interpreted.IsMatch(Input);
    [Benchmark] public bool CompiledOption() => Compiled.IsMatch(Input);
    [Benchmark] public bool NonBacktrackingOption() => NonBacktracking.IsMatch(Input);
    [Benchmark] public bool SourceGenerated() => Generated.IsMatch(Input);
}

The command is dotnet run -c Release bench/regex-bench.cs. I skip the environment information at the start of the output and give only the summary table. This is a measurement made on a single machine; absolute times will differ on yours, while the ratios and the ranking usually stay the same:

| Method                | Mean          | Error        | StdDev       | Ratio    | RatioSD | Gen0   | Gen1   | Allocated | Alloc Ratio |
|---------------------- |--------------:|-------------:|-------------:|---------:|--------:|-------:|-------:|----------:|------------:|
| NewInterpreted        |     938.71 ns |    17.612 ns |    17.298 ns |    18.34 |    0.33 | 0.4778 | 0.0038 |    4000 B |          NA |
| NewCompiled           | 358,805.47 ns | 6,026.581 ns | 9,203.226 ns | 7,010.00 |  178.09 | 1.4648 | 0.4883 |   14391 B |          NA |
| StaticMethod          |      52.59 ns |     0.290 ns |     0.257 ns |     1.03 |    0.01 |      - |      - |         - |          NA |
| Interpreter           |      51.19 ns |     0.154 ns |     0.144 ns |     1.00 |    0.00 |      - |      - |         - |          NA |
| CompiledOption        |      12.81 ns |     0.239 ns |     0.224 ns |     0.25 |    0.00 |      - |      - |         - |          NA |
| NonBacktrackingOption |      26.11 ns |     0.166 ns |     0.147 ns |     0.51 |    0.00 |      - |      - |         - |          NA |
| SourceGenerated       |      10.74 ns |     0.232 ns |     0.206 ns |     0.21 |    0.00 |      - |      - |         - |          NA |

The answer to the guess: C is the fastest, B is about five times slower, and A is not in any of the options. NewCompiled checked one ISBN in about 359 microseconds, SourceGenerated in 10.7 nanoseconds; the gap is more than thirty-three thousand times. The reason is in the first rows of the table: creating a Regex with the Compiled option means parsing the pattern, emitting IL and having the JIT compile it; redoing that on every call pays back the few nanoseconds you gain hundreds of thousands of times. new Regex(Pattern) on every call is not good either: 939 ns and 4000 bytes per call.

The StaticMethod row is surprising at first sight: it passes the pattern as a string on every call, yet it is as fast as the reused interpreter. The static methods keep the most recently used patterns in a cache, so from the second call on the same Regex object is used. Among the long-lived instances, Compiled and the source generator come down to about a quarter of the interpreter, and NonBacktracking stays at half. This is a short, well-behaved input; the real value of NonBacktracking shows up with the badly behaved inputs of the eighth post.

GeneratedRegex: Partial Property, Partial Method and SYSLIB1045

To use the source generator you declare a partial member carrying the [GeneratedRegex] attribute inside a partial class; the generator writes its body. In .NET 7 this had to be a method; since .NET 9 and C# 13 it can also be a property. Let us see it with two Bookstore needs: validating ISBNs and generating the slug used in URLs from a book title.

using System.Text;
using System.Text.RegularExpressions;

foreach (string isbn in new[] { "978-0-441-17271-9", "978-0-441-17271-8", "978 0 441 17271 9" })
    Console.WriteLine($"{isbn,-20} {BookstoreText.IsIsbn13(isbn)}");

foreach (string title in new[] { "Çalıkuşu", "İnce Memed: Cilt 1", "Robinson Crusoe: Issız Bir Adada 28 Yıl", "  Dune -- 2. Baskı  " })
    Console.WriteLine($"{title,-42} {BookstoreText.Slugify(title)}");

static partial class BookstoreText
{
    // .NET 9 and later: a partial property
    [GeneratedRegex(@"^97[89]-[0-9]{1,5}-[0-9]{1,7}-[0-9]{1,7}-[0-9]\z")]
    private static partial Regex Isbn13Shape { get; }

    // .NET 7 and later: a partial method
    [GeneratedRegex(@"\p{Mn}")]
    private static partial Regex CombiningMarks();

    [GeneratedRegex("[^a-z0-9]+")]
    private static partial Regex NonSlugCharacters { get; }

    public static bool IsIsbn13(string input)
    {
        // regex checks the shape, code checks the length and the check digit
        if (input.Length != 17 || !Isbn13Shape.IsMatch(input))
            return false;
        string digits = input.Replace("-", "");
        int sum = 0;
        for (int i = 0; i < 12; i++)
            sum += (digits[i] - '0') * (i % 2 == 0 ? 1 : 3);
        return (10 - sum % 10) % 10 == digits[12] - '0';
    }

    public static string Slugify(string title)
    {
        // FormD splits ç into c + combining cedilla; dotless ı has no decomposition, so map it first
        string decomposed = title.Replace('ı', 'i').Normalize(NormalizationForm.FormD);
        string ascii = CombiningMarks().Replace(decomposed, "").ToLowerInvariant();
        return NonSlugCharacters.Replace(ascii, "-").Trim('-');
    }
}
$ dotnet run dotnet/bookstore-text.cs
978-0-441-17271-9    True
978-0-441-17271-8    False
978 0 441 17271 9    False
Çalıkuşu                                   calikusu
İnce Memed: Cilt 1                         ince-memed-cilt-1
Robinson Crusoe: Issız Bir Adada 28 Yıl    robinson-crusoe-issiz-bir-adada-28-yil
  Dune -- 2. Baskı                         dune-2-baski

On the ISBN side the division of labor is the same as in the first post: regex checks the shape, code checks the check digit. The second ISBN has the right shape, but its last digit is wrong, so it is False.

In the slug function regex works in two places. First the text is decomposed with Unicode's FormD normalization: ç splits into c and an attached cedilla mark, İ into I and the dot above it. \p{Mn}, the "non-spacing mark" from the Unicode categories we saw in the fourth post, removes all those attached marks with a single pattern. The only exception in Turkish is the dotless ı: it has nothing to decompose, so it is mapped to i by hand first. [^a-z0-9]+ reduces everything else to a single hyphen; Trim('-') drops the leading and trailing hyphens. This method needs no culture data, so it does not run into the invariant mode problem from the fourth post.

If you want to see the code the generator produced, Go To Definition on the property in Visual Studio is enough; according to the source generator page on Microsoft Learn, the code comes with XML comments that explain the pattern step by step, and you can set breakpoints in it. There is also an analyzer that finds new Regex(...) and Regex.IsMatch(..., "...") calls whose pattern is known at build time: SYSLIB1045. It shows up as a suggestion in the IDE, and its fixer turns the call into [GeneratedRegex]. To see it in the build output I put an .editorconfig in the folder and raised its severity to warning:

$ cat dotnet/syslib1045/.editorconfig
root = true

# show SYSLIB1045 (by default an IDE suggestion) as a build warning
[*.cs]
dotnet_diagnostic.SYSLIB1045.severity = warning
$ cat dotnet/syslib1045/legacy.cs
using System.Text.RegularExpressions;

// constant patterns built at run time: the analyzer suggests GeneratedRegex for both
Regex orderNumber = new("BKS-[0-9]{4}-[0-9]{6}");
Console.WriteLine(orderNumber.IsMatch("BKS-2026-000123"));
Console.WriteLine(Regex.IsMatch("978-0-441-17271-9", "^97[89]-"));
$ dotnet build --no-incremental dotnet/syslib1045/legacy.cs 2>&1 | grep -o 'legacy.cs([0-9,]*): warning SYSLIB1045: [^(]*time\.'
legacy.cs(4,21): warning SYSLIB1045: Use 'GeneratedRegexAttribute' to generate the regular expression implementation at compile-time.
legacy.cs(6,19): warning SYSLIB1045: Use 'GeneratedRegexAttribute' to generate the regular expression implementation at compile-time.

--no-incremental is there on purpose: if the file has not changed, the build skips compilation and the warnings do not show up at all on the second build.

The one thing the generator cannot do is generate code for NonBacktracking; when that option is given, according to the documentation, it falls back to a cached ordinary Regex instance. It ignores RegexOptions.Compiled, because the code it produces is already compiled.

Match, Matches, Replace, Split and the Allocation-Free APIs

The APIs of the Regex class fall into two groups: the classic methods that return results as Match objects and strings, and the methods that came after .NET 7, which work with ReadOnlySpan<char> and do not allocate:

using System.Text.RegularExpressions;

string log = File.ReadAllText("data/app.log");

// Count, Match, Matches
Console.WriteLine($"Count: {Patterns.OrderNumber.Count(log)}");
Console.WriteLine($"Match: {Patterns.OrderNumber.Match(log).Value}");
Console.WriteLine($"Matches: {string.Join(", ", Patterns.OrderNumber.Matches(log).Select(m => m.Value[^3..]))}");

// Replace with a MatchEvaluator: the replacement is computed per match
string firstLine = log[..log.IndexOf('\n')];
Console.WriteLine(Patterns.OrderNumber.Replace(firstLine, m => m.Value[..9] + "****" + m.Value[^2..]));

// Split keeps empty entries; EnumerateSplits (.NET 9) returns ranges instead of strings
string titles = "Dune; Emma ,Middlemarch;;The Hobbit";
Console.WriteLine(string.Join(" | ", Patterns.Separator.Split(titles)));
foreach (Range range in Patterns.Separator.EnumerateSplits(titles))
    Console.Write($"[{titles[range]}]");
Console.WriteLine();

// IsMatch on a ReadOnlySpan<char>: a slice of the line, no Substring
ReadOnlySpan<char> lastFifteen = firstLine.AsSpan()[^15..];
Console.WriteLine($"IsMatch(span): {Patterns.OrderNumber.IsMatch(lastFifteen)}");

// allocations: Matches creates Match objects and strings, EnumerateMatches does not
Measure("Matches", () => { int n = 0; foreach (Match m in Patterns.OrderNumber.Matches(log)) n += m.Value.Length; return n; });
Measure("EnumerateMatches", () => { int n = 0; foreach (ValueMatch m in Patterns.OrderNumber.EnumerateMatches(log)) n += m.Length; return n; });

static void Measure(string name, Func<int> work)
{
    work(); // warm-up, so the JIT does not count
    long before = GC.GetAllocatedBytesForCurrentThread();
    int chars = work();
    long after = GC.GetAllocatedBytesForCurrentThread();
    Console.WriteLine($"{name,-17} {chars} chars, {after - before} bytes allocated");
}

static partial class Patterns
{
    [GeneratedRegex("BKS-[0-9]{4}-[0-9]{6}")]
    public static partial Regex OrderNumber { get; }

    [GeneratedRegex(@"\s*[;,]\s*")]
    public static partial Regex Separator { get; }
}
$ dotnet run dotnet/regex-apis.cs
Count: 4
Match: BKS-2026-000123
Matches: 123, 124, 125, 126
2026-11-15T10:42:07Z INF Bookstore.Payments Payment 4711 authorized for BKS-2026-****23
Dune | Emma | Middlemarch |  | The Hobbit
[Dune][Emma][Middlemarch][][The Hobbit]
IsMatch(span): True
Matches           60 chars, 1232 bytes allocated
EnumerateMatches  60 chars, 0 bytes allocated

Count (.NET 7) only counts and creates no Match objects; you do not need Matches(...).Count for "how many are there". The Replace overload that takes a MatchEvaluator lets you compute the replacement in code for each match; here we masked the middle of the order number. The log refactor in the ★★★ exercise of the sixth post is written with this overload too. Split leaves an empty piece between two consecutive separators; because of ;; the output has nothing between two |.

The last two lines show the difference in numbers. Matches allocated 1232 bytes for four matches: one MatchCollection, four Match objects and four strings produced by Value. EnumerateMatches allocated zero bytes, because it returns a ref struct enumerator and ValueMatch values that carry only Index and Length. The price: ValueMatch has no group information. Use the span APIs for counting, filtering and finding positions where you do not need groups, and in places that process thousands of lines per second; if you need groups, go back to Match.

Timeouts and NonBacktracking

The two defenses .NET offers against the catastrophic backtracking we will measure in the eighth post are timeouts and NonBacktracking. Let us see both with the same bad pattern, ^(a+)+$, and an input that almost matches:

using System.Diagnostics;
using System.Text.RegularExpressions;

// a process-wide default timeout; it is read once, so set it before the first Regex is used
AppContext.SetData("REGEX_DEFAULT_MATCH_TIMEOUT", TimeSpan.FromSeconds(2));

// a nested quantifier and an input that almost matches
string input = new string('a', 30) + "!";

// 1. timeout: the backtracking engine gives up after 100 ms
Regex withTimeout = new(@"^(a+)+$", RegexOptions.None, TimeSpan.FromMilliseconds(100));
try
{
    withTimeout.IsMatch(input);
}
catch (RegexMatchTimeoutException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.MatchTimeout.TotalMilliseconds} ms");
}

// 2. NonBacktracking: same pattern, same input, linear time
Stopwatch sw = Stopwatch.StartNew();
bool matched = Regex.IsMatch(input, @"^(a+)+$", RegexOptions.NonBacktracking);
Console.WriteLine($"NonBacktracking: {matched} in under 100 ms: {sw.ElapsedMilliseconds < 100}");

// 3. constructs that need backtracking are rejected when the Regex is constructed
foreach (string pattern in new[] { @"Payment (?=\d)", @"\b(\w+) \1\b", @"(?>\d+)0", @"(?<=BKS-)\d+" })
{
    try
    {
        _ = new Regex(pattern, RegexOptions.NonBacktracking);
        Console.WriteLine($"{pattern,-16} ok");
    }
    catch (NotSupportedException ex)
    {
        Console.WriteLine($"{pattern,-16} {ex.Message}");
    }
}

// 4. every Regex created without a timeout gets the process-wide default
Console.WriteLine($"default timeout: {new Regex("BKS").MatchTimeout}");
$ dotnet run dotnet/timeout-nonbacktracking.cs
RegexMatchTimeoutException: 100 ms
NonBacktracking: False in under 100 ms: True
Payment (?=\d)   RegexOptions.NonBacktracking is not supported in conjunction with expressions containing: 'positive lookahead (?= pattern) or positive lookbehind (?<= pattern)'.
\b(\w+) \1\b     RegexOptions.NonBacktracking is not supported in conjunction with expressions containing: 'backreference (\ number)'.
(?>\d+)0         RegexOptions.NonBacktracking is not supported in conjunction with expressions containing: 'atomic subexpressions (?> pattern)'.
(?<=BKS-)\d+     RegexOptions.NonBacktracking is not supported in conjunction with expressions containing: 'positive lookahead (?= pattern) or positive lookbehind (?<= pattern)'.
default timeout: 00:00:02

The default timeout is infinite. When you set a value, the engine throws RegexMatchTimeoutException once it runs out; according to the .NET documentation the time may be exceeded slightly, but no more time is spent than the interval given to backtracking. Whether you catch the exception and count it as "no match" or log it and reject the request is your decision. You can set the timeout in three places: the Regex constructor, the matchTimeout parameter of the static methods and the matchTimeoutMilliseconds parameter of [GeneratedRegex]. If none is given, the process-wide REGEX_DEFAULT_MATCH_TIMEOUT setting applies. While trying this setting I noticed something: when I put the SetData call at the end of the program, after a few Regex objects had been used, the new Regex still had an infinite timeout; the value is read once. That is why the setting is in the first lines of the program.

NonBacktracking fixes the problem at the root: like the RE2 family from the fifth post it reads the input once, and on the same input it returned False without coming anywhere near the timeout. In return there are constructs it does not support, and these are rejected with NotSupportedException when the Regex is constructed, not during matching. The list on the RegexOptions page on Microsoft Learn: atomic groups, backreferences, balancing groups, conditionals, lookarounds and \G. There is one more difference: for a group inside a loop it gives only the last captured value, not all the captures like the backtracking engine.

One more warning from the .NET documentation. The engine assumes the pattern is trusted; timeouts and NonBacktracking protect against expensive input, not against malicious patterns. Do not run text typed by users as a pattern: either turn it into a literal with Regex.Escape, or offer a simple wildcard syntax as in the fifth post and translate it to regex yourself.

IgnoreCase and Culture: new Regex and GeneratedRegex Behave Differently

In the fourth post we saw that the result of IgnoreCase depends on the culture. When exactly the culture is read, and what happens in the source generator, matters too:

using System.Globalization;
using System.Text.RegularExpressions;

string title = "ROBINSON CRUSOE: ISSIZ BİR ADADA 28 YIL";

// new Regex: the culture is read when the Regex is constructed, not when it matches
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR");
Regex builtUnderTurkish = new("ıssız", RegexOptions.IgnoreCase);
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Regex builtUnderInvariant = new("ıssız", RegexOptions.IgnoreCase);
Console.WriteLine($"new Regex, built under tr-TR:     {builtUnderTurkish.IsMatch(title)}");
Console.WriteLine($"new Regex, built under invariant: {builtUnderInvariant.IsMatch(title)}");

// GeneratedRegex: the culture is fixed at compile time by cultureName
Console.WriteLine($"generated, cultureName tr-TR:     {Titles.IssizTurkish.IsMatch(title)}");
Console.WriteLine($"generated, no cultureName:        {Titles.InceDefault.IsMatch("İNCE MEMED")}");

// the same pattern and options a second time, now with tr-TR
Console.WriteLine($"generated, same pattern, tr-TR:   {Titles.InceTurkish.IsMatch("İNCE MEMED")}");
Console.WriteLine($"same instance: {ReferenceEquals(Titles.InceDefault, Titles.InceTurkish)}");

static partial class Titles
{
    [GeneratedRegex("ıssız", RegexOptions.IgnoreCase, "tr-TR")]
    public static partial Regex IssizTurkish { get; }

    [GeneratedRegex("ince", RegexOptions.IgnoreCase)]
    public static partial Regex InceDefault { get; }

    [GeneratedRegex("ince", RegexOptions.IgnoreCase, "tr-TR")]
    public static partial Regex InceTurkish { get; }
}
$ dotnet run dotnet/ignorecase-culture.cs
new Regex, built under tr-TR:     True
new Regex, built under invariant: False
generated, cultureName tr-TR:     True
generated, no cultureName:        False
generated, same pattern, tr-TR:   False
same instance: True

The first two lines: if the Regex was created under the Turkish culture, it keeps matching with Turkish rules even after the culture changes. The culture is read at construction, not at match time. A Regex created in a static readonly field at application start carries whatever culture the thread had at that moment.

In the source generator the culture has to be known at build time, because the case mappings are baked into the generated code. With tr-TR as cultureName, ıssız found ISSIZ. Without cultureName the run-time culture is not consulted; ince did not find İNCE.

The last two lines are a behavior I ran into while trying this: when I declared the same pattern with the same options twice, once without a culture and once with tr-TR, the generator produced a single implementation for both and ignored the culture of the second declaration; ReferenceEquals is True. Which declaration wins depends on the order in the source code. I observed this with SDK 10.0.404 and 11.0.100; the documentation does not describe this case. The practical consequence is simple: if you need a culture-aware and a culture-free version of a pattern, do not give both to the source generator in the same project; create one with new Regex and pass its culture explicitly.

In Production

If the pattern is known at build time, use [GeneratedRegex]; following SYSLIB1045's suggestions is enough. If the pattern is only known at run time (such as a log filter that comes from configuration), create the Regex once and keep it. According to the documentation the static methods' cache holds the 15 most recently used patterns; if you cycle more distinct patterns through the static methods, each one is parsed again and you end up on the NewInterpreted row of the benchmark. You can raise Regex.CacheSize if needed, but keeping them in your own ConcurrentDictionary is more predictable.

Do not write new Regex(..., RegexOptions.Compiled) on every call. Compiled means "build once expensively, use many times fast"; built on every call it becomes the most expensive option. In environments where dynamic code cannot be generated, such as Native AOT, Compiled has no effect, while the source generator keeps working.

Give every Regex that processes untrusted input a timeout or use NonBacktracking. Log RegexMatchTimeoutException: knowing which pattern timed out on what length of input is the quickest way to notice the problem of the eighth post in production.

Common Pitfalls

Putting user text into a pattern without Regex.Escape

A search for C++ throws a Nested quantifier exception, and a search for Dune (1965) opens a group and finds the text without parentheses. The search term must always go through Regex.Escape; if you want users to write regex, do not trust the pattern.

Assuming the result of IgnoreCase is fixed

new Regex carries the culture of the moment it was created; [GeneratedRegex] never looks at the run-time culture unless cultureName is given. Pass the culture explicitly for Turkish text; use CultureInvariant for system identifiers.

Adding NonBacktracking to every pattern

A pattern with lookaround, backreferences or atomic groups throws an exception when it is constructed. Write a test that constructs every pattern you add the option to at least once.

Trying to read groups with EnumerateMatches

ValueMatch carries only a position and a length. If you need groups, use Match, or process the matched slice with a second Regex.

Setting REGEX_DEFAULT_MATCH_TIMEOUT too late

The value is read once. A SetData call made after the first Regex is used has no effect; put the setting at application start.

A Small Experiment

★☆☆ Change the Separator pattern in regex-apis.cs so that consecutive separators such as ;; produce no empty pieces. Compare the output of Split and EnumerateSplits.

★★☆ Add four methods to the benchmark that call Count on the whole of data/access.log: interpreter, Compiled, source generator and NonBacktracking. Does the ranking stay the same as with the ISBN on a long input?

★★★ For Bookstore's search endpoint, write a search function that supports the * and ? wildcards typed by users: escape the text with Regex.Escape, then turn only the escaped \* and \? into .* and ., give it a 100 ms timeout and log the timeout.

With this we have covered the .NET side too. In the last post of the series we look at the cost of regex: we see catastrophic backtracking by measuring it, read the official report of Cloudflare's 2019 outage, compare the defenses, and talk about ways to keep regex readable and tested.