Matching, Replacing and Splitting
Every Pattern can run itself. You do not need to call ToRegex() first: a pattern tests, finds, counts, replaces and splits text directly. These methods wrap System.Text.RegularExpressions.Regex, so they return the standard Match, MatchCollection and string[] types and behave exactly like the matching Regex methods.
Use the methods on this page whenever you have a pattern and a string. To bind matches to typed objects, see Typed Extraction. To read input that does not fit in a string, such as a large file, see Streaming.
using System;
using System.Text.RegularExpressions;
using Masterly.FluentRegex;
Pattern digits = Pattern.With.Digit.Repeat.OneOrMore;
bool hasDigits = digits.IsMatch("abc123def"); // true
Match first = digits.Match("abc123def456"); // first.Value is "123"
int count = digits.Count("a1b22c333"); // 3
string masked = digits.Replace("PIN 1234", "****"); // "PIN ****"
string[] letters = Pattern.With.Literal(",").Split("a,b,c"); // ["a", "b", "c"]
The pattern builds its Regex on first use and caches it. Because a pattern is immutable, the cached Regex can never go stale. See Performance and thread safety.
| Method | Returns | Use it when |
|---|---|---|
IsMatch(input) | bool | You only need a yes/no answer |
Match(input), Match(input, startAt) | Match | You want the first match, its position or its groups |
Matches(input) | MatchCollection | You want every match |
Count(input) | int | You only need the number of matches |
Replace(input, ...) | string | You want to rewrite the matches |
Split(input), Split(input, count) | string[] | You want the text between the matches |
EnumerateMatches(span), EnumerateSplits(span) | Enumerators of positions | You want to avoid allocations on modern .NET (see Span APIs on modern .NET) |
Testing for a match
IsMatch returns true when the pattern matches anywhere in the input.
Pattern digits = Pattern.With.Digit.Repeat.OneOrMore;
Console.WriteLine(digits.IsMatch("abc123def")); // True
Console.WriteLine(digits.IsMatch("no numbers")); // False
Matching the whole string
"Anywhere" is the important word. \d+ is found inside abc123, so IsMatch returns true. To accept only input that is digits from start to end, anchor the pattern at both ends with StartOfInput and EndOfInput.
Pattern digits = Pattern.With.Digit.Repeat.OneOrMore;
Pattern onlyDigits = Pattern.With.StartOfInput.Digit.Repeat.OneOrMore.EndOfInput;
Console.WriteLine(digits.IsMatch("abc123")); // True
Console.WriteLine(onlyDigits.IsMatch("abc123")); // False
Console.WriteLine(onlyDigits.IsMatch("123")); // True
Console.WriteLine(onlyDigits); // \A\d+\z
Prefer StartOfInput / EndOfInput (\A / \z) over StartOfLine / EndOfLine (^ / $) for validation. $ also matches before a final newline, so a value with a trailing \n slips through:
// onlyDigits is from the previous example
Pattern lineDigits = Pattern.With.StartOfLine.Digit.Repeat.OneOrMore.EndOfLine;
Console.WriteLine(lineDigits.IsMatch("123\n")); // True
Console.WriteLine(onlyDigits.IsMatch("123\n")); // False
Some features need to know which of these two meanings you have in mind. They take a MatchScope: MatchScope.Substring means "what IsMatch accepts" and MatchScope.FullString means "the whole string matches", as if the pattern were wrapped in \A(?:...)\z (an explicit $ in the pattern still accepts a final newline). Language facts default to FullString (see Language Facts) and LIKE translation defaults to Substring (see SQL LIKE).
Testing with other options
IsMatch(input, options) runs the pattern once with different RegexOptions.
Pattern hello = Pattern.With.Literal("hello");
Pattern ignoreCase = hello.WithOptions(RegexOptions.IgnoreCase);
Console.WriteLine(hello.IsMatch("HELLO", RegexOptions.IgnoreCase)); // True
Console.WriteLine(ignoreCase.IsMatch("HELLO", RegexOptions.Multiline)); // False
Console.WriteLine(ignoreCase.IsMatch("HELLO", ignoreCase.Options | RegexOptions.Multiline)); // True
- The options replace the pattern's own options. They are not added to them. In the second line
IgnoreCaseis lost, so the test fails. Combine them yourself withpattern.Options | ..., as in the third line. - The pattern's timeout still applies.
- This overload builds a new
Regexon every call and does not use the cache. Calling it in a loop is much slower than callingIsMatch(input)on a pattern built withWithOptions.
If you always test with the same options, build the pattern once with WithOptions (see Options) and store it in a static readonly field. Keep IsMatch(input, options) for one-off checks.
Finding the first match
Match returns the first match. Check Success before you read the value: when nothing matches, you get a failed match, never null.
Pattern digits = Pattern.With.Digit.Repeat.OneOrMore;
Match first = digits.Match("abc123def456");
Console.WriteLine(first.Success); // True
Console.WriteLine(first.Value); // 123
Console.WriteLine(first.Index); // 3
Console.WriteLine(first.Length); // 3
Match second = first.NextMatch();
Console.WriteLine(second.Value); // 456
Match none = digits.Match("no numbers");
Console.WriteLine(none.Success); // False
Console.WriteLine(none.Value == string.Empty); // True
Indexis the position of the first matched character.Lengthis the number of matched characters.NextMatch()continues the search after the current match. A loop overMatchandNextMatch()gives the same matches asMatches.- A failed match has
Successfalse, an emptyValue, andIndexandLengthof zero.
Starting at a position
Match(input, startAt) starts the search at a character position.
Pattern digits = Pattern.With.Digit.Repeat.OneOrMore;
Console.WriteLine(digits.Match("abc123def456", 5).Value); // 3
Console.WriteLine(digits.Match("abc123def456", 6).Value); // 456
The search starts at startAt, but the engine still sees the whole input. That has three effects:
Pattern atStart = Pattern.With.StartOfLine.Digit.Repeat.OneOrMore;
Pattern contiguous = Pattern.With.RegEx(@"\G").Digit.Repeat.OneOrMore;
Pattern afterLetter = Pattern.With.PositiveLookbehind(Pattern.With.Letter).Digit.Repeat.OneOrMore;
Console.WriteLine(atStart.Match("ab12", 2).Success); // False
Console.WriteLine(contiguous.Match("ab12", 2).Success); // True
Console.WriteLine(afterLetter.Match("ab12", 2).Value); // 12
^(StartOfLine) and\A(StartOfInput) still mean the start of the input, notstartAt. They do not match at position 2.\Gmeans "where this search started". Use it to require a match exactly atstartAt.- Lookbehinds can look at the characters before
startAt. Here the lookbehind sees thebat position 1. startAtmust be between0andinput.Length. Any other value throwsArgumentOutOfRangeException.
Reading groups
A match carries its groups. Read a named group with Groups["name"].
Pattern assignment = Pattern.With
.NamedGroup("name", Pattern.With.Word.Repeat.OneOrMore)
.Literal("=")
.NamedGroup("value", Pattern.With.Digit.Repeat.OneOrMore);
Match match = assignment.Match("timeout=30");
Console.WriteLine(match.Groups["name"].Value); // timeout
Console.WriteLine(match.Groups["value"].Value); // 30
Console.WriteLine(match.Groups["missing"].Success); // False
- A group name that does not exist does not throw. You get a group with
Successfalse and an emptyValue. - Numbered groups work too:
match.Groups[1]. Group 0 is the whole match. - To turn groups into a typed object instead of reading strings, see Typed Extraction and Templates. For group syntax, see Groups and Backreferences.
Finding all matches
Matches returns every match, from left to right.
Pattern digits = Pattern.With.Digit.Repeat.OneOrMore;
MatchCollection all = digits.Matches("a1b22c333d4444");
foreach (Match match in all)
{
Console.WriteLine($"'{match.Value}' at index {match.Index}");
}
// prints:
// '1' at index 1
// '22' at index 3
// '333' at index 6
// '4444' at index 10
Console.WriteLine(all.Count); // 4
- Matches never overlap. The next search starts where the previous match ended, so
\d{2}finds12and34in12345, not23or45. - When nothing matches, the collection is empty. It is never
null. MatchCollectionis filled lazily as you iterate. ReadingCountscans the whole input. If you only need the number, callCountinstead.
A pattern that can match nothing, such as \d*, also finds empty matches between characters:
Pattern optionalDigits = Pattern.With.Digit.Repeat.ZeroOrMore;
foreach (Match match in optionalDigits.Matches("a1"))
{
Console.WriteLine($"[{match.Value}] at {match.Index}");
}
// prints:
// [] at 0
// [1] at 1
// [] at 2
Empty matches are a common source of surprising counts and replacements. \d* counts 3 matches in a1, and Replace("a1", "-") returns -a--. Use OneOrMore instead of ZeroOrMore when an empty match makes no sense.
Counting matches
Count returns the number of matches without building a MatchCollection.
Pattern word = Pattern.With.Word.Repeat.OneOrMore;
Console.WriteLine(word.Count("the quick brown fox")); // 4
Console.WriteLine(word.Count(string.Empty)); // 0
Count(string)works on every target framework. On .NET 8 and later it callsRegex.Count, which does not allocateMatchobjects. Onnetstandard2.1it walks the matches withMatchandNextMatch(), which gives the same number but allocates.- On .NET 8 and later there is also
Count(ReadOnlySpan<char>)(see Span APIs on modern .NET). - A
nullinput throwsArgumentNullExceptionon every target.
Replacing text
Replace rewrites every match. It returns a new string, and returns the input unchanged when nothing matches.
Pattern digits = Pattern.With.Digit.Repeat.OneOrMore;
Console.WriteLine(digits.Replace("a1b2c3", "X")); // aXbXcX
Console.WriteLine(digits.Replace("no digits", "X")); // no digits
There are four overloads, plus one for fluent replacements:
| Overload | Replaces |
|---|---|
Replace(input, replacement) | Every match, with a replacement string |
Replace(input, replacement, count) | The first count matches, with a replacement string |
Replace(input, evaluator) | Every match, with the string a function returns |
Replace(input, evaluator, count) | The first count matches, with the string a function returns |
Replace(input, build), Replace(input, build, count) | Every match (or the first count), with a replacement built fluently (see Fluent Replacements) |
Replacement strings
A replacement string can refer to the match and its groups with $ substitutions.
Pattern assignment = Pattern.With
.NamedGroup("name", Pattern.With.Word.Repeat.OneOrMore)
.Literal("=")
.NamedGroup("value", Pattern.With.Digit.Repeat.OneOrMore);
Console.WriteLine(assignment.Replace("x=1 y=2 z=3", "${name}: ${value}")); // x: 1 y: 2 z: 3
Console.WriteLine(assignment.Replace("x=1 y=2", "$2=$1")); // 1=x 2=y
Console.WriteLine(assignment.Replace("x=1 y=2", "[$0]")); // [x=1] [y=2]
These are the substitutions .NET supports. The last column shows the result of assignment.Replace("a x=1 b", "<...>") with the substitution inside the angle brackets:
| Substitution | Inserts | a x=1 b becomes |
|---|---|---|
$0 or $& | The whole match | a <x=1> b |
$1, $2, ... | A numbered group | $2 gives a <1> b |
${name} | A named group | ${name} gives a <x> b |
$` | The text before the match | a <a > b |
$' | The text after the match | a < b> b |
$+ | The last group | a <1> b |
$_ | The entire input | a <a x=1 b> b |
$$ | A literal $ | a <$> b |
- A substitution that names a group that does not exist, such as
${unknown}or$9, is not an error. It stays in the output as literal text. - To put a literal
$right before a group, double it:$$then${amount}.
Pattern amount = Pattern.With.NamedGroup("amount", Pattern.With.Digit.Repeat.OneOrMore);
Console.WriteLine(amount.Replace("Total: 42", "$$${amount}.00")); // Total: $42.00
Console.WriteLine(amount.Replace("Total: 42", "$${amount}")); // Total: ${amount}
Never pass user input as a replacement string without escaping it. A user-supplied $0 or ${name} is interpreted, not copied. To insert untrusted text literally, double every $ (text.Replace("$", "$$")) or use an evaluator, whose return value is never interpreted.
Limiting the number of replacements
Pass count to replace only the first matches.
Pattern digits = Pattern.With.Digit.Repeat.OneOrMore;
Console.WriteLine(digits.Replace("a1b2c3d4", "X", 2)); // aXbXc3d4
Console.WriteLine(digits.Replace("a1b2c3d4", "X", 0)); // a1b2c3d4
Console.WriteLine(digits.Replace("a1b2c3d4", "X", -1)); // aXbXcXdX
count | Effect |
|---|---|
| A positive number | Replaces at most that many matches, from the left |
0 | Replaces nothing |
-1 | Replaces every match, like the overload without count |
Below -1 | Throws ArgumentOutOfRangeException |
Replacing with a function
Pass a MatchEvaluator (any Func<Match, string>-shaped lambda) when the new text depends on the match.
Pattern digits = Pattern.With.Digit.Repeat.OneOrMore;
string tenTimes = digits.Replace("a1b2c3", match => (int.Parse(match.Value) * 10).ToString());
Console.WriteLine(tenTimes); // a10b20c30
string firstTwo = digits.Replace("a1b2c3", match => $"<{match.Value}>", 2);
Console.WriteLine(firstTwo); // a<1>b<2>c3
- The function runs once per match, in order, and receives the full
Match, including its groups. - Its return value is inserted literally. Returning
"$0"inserts the two characters$0, not the match. - A
nullevaluator throwsArgumentNullException.
Replacing with a fluent replacement
Instead of a $ string, you can build the replacement with the same fluent style as the pattern. Group names and numbers are checked against the pattern before anything is replaced: a group the pattern does not have throws ArgumentException, instead of being copied as literal text.
Pattern date = Pattern.With
.NamedGroup("year", Pattern.With.Digit.Repeat.Times(4))
.Literal("-")
.NamedGroup("month", Pattern.With.Digit.Repeat.Times(2))
.Literal("-")
.NamedGroup("day", Pattern.With.Digit.Repeat.Times(2));
string european = date.Replace("Due 2026-11-03", replacement => replacement
.Group("day").Literal("/").Group("month").Literal("/").Group("year"));
Console.WriteLine(european); // Due 03/11/2026
string firstYearOnly = date.Replace("2026-11-03 2027-01-02", replacement => replacement.Group("year"), 1);
Console.WriteLine(firstYearOnly); // 2026 2027-01-02
Replace(input, build, count)takes the samecountvalues as the string overloads.- The groups are checked once, before the first replacement.
Group("nope")throwsArgumentExceptionwith a message that lists the available groups:Group 'nope' does not exist in the pattern ... Available groups: 0, year, month, day. - A
nullbuilder throwsArgumentNullException. A builder that returnsnullthrowsInvalidOperationException("The replacement builder returned null."). - The fluent overloads use the same cached
Regexas the other methods.
The full Replacement API is described in Fluent Replacements.
Splitting text
Split cuts the input at every match and returns the pieces between them.
Pattern comma = Pattern.With.Literal(",");
Pattern whitespace = Pattern.With.Whitespace.Repeat.OneOrMore;
string[] letters = comma.Split("a,b,c,d");
string[] words = whitespace.Split("hello world\ttest");
Console.WriteLine(string.Join(" | ", letters)); // a | b | c | d
Console.WriteLine(string.Join(" | ", words)); // hello | world | test
Empty pieces
Split keeps empty pieces. A delimiter at the start or end, or two delimiters in a row, give an empty string.
using System.Linq;
Pattern comma = Pattern.With.Literal(",");
string[] parts = comma.Split(",a,,b,");
Console.WriteLine(parts.Length); // 5
Console.WriteLine(string.Join("|", parts)); // |a||b|
string[] nonEmpty = parts.Where(part => part.Length > 0).ToArray();
Console.WriteLine(string.Join("|", nonEmpty)); // a|b
- When nothing matches, you get a one-element array that holds the whole input.
- Splitting an empty string gives a one-element array that holds an empty string.
- There is no
RemoveEmptyEntriesoption. Filter the array as shown, or match the pieces instead of the delimiters withMatches.
Captured delimiters
When the pattern has capturing groups, the captured text is added to the result, between the pieces. Use this to keep the delimiters.
Pattern operators = Pattern.With.Group(Pattern.With.AnyOf("+-"));
Pattern quietOperators = Pattern.With.AnyOf("+-");
Console.WriteLine(string.Join(" | ", operators.Split("1+2-3"))); // 1 | + | 2 | - | 3
Console.WriteLine(string.Join(" | ", quietOperators.Split("1+2-3"))); // 1 | 2 | 3
If you group the delimiter only to apply a quantifier, use NonCapturingGroup (or RegexOptions.ExplicitCapture) so the delimiters stay out of the result.
Limiting the number of pieces
Pass count to get at most that many pieces. The last piece holds the rest of the input.
Pattern dash = Pattern.With.Literal("-");
Console.WriteLine(string.Join(" | ", dash.Split("a-b-c-d-e", 3))); // a | b | c-d-e
count | Effect |
|---|---|
| A number above 1 | At most that many pieces |
1 | One piece: the whole input |
0 | No limit, like the overload without count |
| A negative number | Throws ArgumentOutOfRangeException |
Options
WithOptions returns a copy of the pattern that runs with the given RegexOptions. The Options property tells you which options a pattern has.
Pattern hello = Pattern.With.Literal("hello");
Pattern ignoreCase = hello.WithOptions(RegexOptions.IgnoreCase);
Console.WriteLine(hello.Options); // None
Console.WriteLine(ignoreCase.Options); // IgnoreCase
Console.WriteLine(hello.IsMatch("HELLO")); // False
Console.WriteLine(ignoreCase.IsMatch("HELLO")); // True
Console.WriteLine(ignoreCase); // hello
WithOptionsnever changes the original pattern.hellostill matches case-sensitively.- Options are not part of the regex text.
ToString()returnshellofor both patterns. They are applied when the pattern runs, by every method on this page, byToRegex()and by the code the source generator emits. - Options take part in equality:
hello.Equals(ignoreCase)isfalse.
Replacing or adding options
WithOptions replaces all options, like IsMatch(input, options). To add an option, combine it with the current ones.
// ignoreCase is from the previous example
Pattern multilineOnly = ignoreCase.WithOptions(RegexOptions.Multiline);
Pattern both = ignoreCase.WithOptions(ignoreCase.Options | RegexOptions.Multiline);
Console.WriteLine(multilineOnly.Options); // Multiline
Console.WriteLine(both.Options); // IgnoreCase, Multiline
How options travel through composition
Options belong to the chain you call WithOptions on. They carry forward as you extend that chain, but they do not come along when the pattern is nested inside another one.
Pattern code = Pattern.With.Literal("abc").WithOptions(RegexOptions.IgnoreCase);
Pattern extended = code.Literal("-x");
Pattern nested = Pattern.With.Literal("id:").Group(code);
Pattern inline = Pattern.With.Literal("id:").IgnoreCase(Pattern.With.Literal("abc"));
Console.WriteLine(extended.Options); // IgnoreCase
Console.WriteLine(nested.Options); // None
Console.WriteLine(nested.IsMatch("id:ABC")); // False
Console.WriteLine(inline); // id:(?i:abc)
Console.WriteLine(inline.IsMatch("id:ABC")); // True
Console.WriteLine(inline.IsMatch("ID:ABC")); // False
| How you combine | Options of the result |
|---|---|
Extending a chain: code.Literal(...), code.Then(other), code + other | The options of code, the left side |
Nesting: Group(code), OneOf(code, ...), other.Then(code), other + code | The options of the outer chain; those of code are dropped |
code | other | The options of code, the left side, like + |
When only part of a pattern should ignore case, do not rely on WithOptions of a nested pattern. Use the inline scopes IgnoreCase(pattern), CaseSensitive(pattern) and InlineOptions(enable, pattern, disable). They are rendered into the regex text, so they survive any composition (see Composing Patterns).
Which options to use
| Option | Effect |
|---|---|
IgnoreCase | Case-insensitive matching |
Multiline | ^ and $ match at every line break, not only at the ends of the input |
Singleline | . (Anything) also matches \n |
ExplicitCapture | Only named groups capture; plain (...) groups do not |
IgnorePatternWhitespace | Unescaped spaces in the regex are ignored and # starts a comment |
RightToLeft | The search runs from the end of the input towards the start |
CultureInvariant | Case-insensitive comparisons ignore the current culture |
ECMAScript | ECMAScript behavior. Can be combined only with IgnoreCase, Multiline, CultureInvariant and Compiled |
Compiled | Compiles the regex to IL. Slower first use, faster matching. Worth it because the pattern caches its Regex |
NonBacktracking | The linear-time engine on .NET 8 and later. Prefer UseNonBacktracking() (see The non-backtracking engine) |
Multiline and Singleline change what anchors and Anything mean:
Pattern line = Pattern.With.StartOfLine.Word.Repeat.OneOrMore.EndOfLine;
string text = "alpha\nbeta\ngamma";
Console.WriteLine(line.Matches(text).Count); // 0
Console.WriteLine(line.WithOptions(RegexOptions.Multiline).Matches(text).Count); // 3
Pattern tag = Pattern.With.Literal("<").Anything.Repeat.ZeroOrMore.Lazy.Literal(">");
Console.WriteLine(tag.IsMatch("<a\nb>")); // False
Console.WriteLine(tag.WithOptions(RegexOptions.Singleline).IsMatch("<a\nb>")); // True
RightToLeft finds the last match first:
Pattern lastNumber = Pattern.With.Digit.Repeat.OneOrMore.WithOptions(RegexOptions.RightToLeft);
Console.WriteLine(lastNumber.Match("a1b22c333").Value); // 333
Console.WriteLine(lastNumber.Match("a1b22c333").NextMatch().Value); // 22
Under IgnorePatternWhitespace, literals escape their spaces and #, so Pattern.With.Literal("a b").Literal("#") with that option renders a\ b\# and still matches a b#. Text added with RegEx(...) is not changed.
An invalid combination, such as ECMAScript with Singleline, is not reported by WithOptions. The Regex constructor throws ArgumentOutOfRangeException when the pattern first runs. When the pattern and its options are constants, the analyzer reports the problem at compile time as error FRX003 (see Compile-Time Checks).
Getting a Regex
Use ToRegex() when an API needs a System.Text.RegularExpressions.Regex, for example a validation attribute or a library you pass the regex to.
Pattern word = Pattern.With.Word.Repeat.OneOrMore
.WithOptions(RegexOptions.IgnoreCase)
.WithTimeout(TimeSpan.FromSeconds(1));
Regex regex = word.ToRegex();
Console.WriteLine(regex); // \w+
Console.WriteLine(regex.Options); // IgnoreCase
Console.WriteLine(regex.MatchTimeout); // 00:00:01
Regex compiled = word.ToRegex(RegexOptions.Compiled);
Console.WriteLine(compiled.Options); // Compiled
Console.WriteLine(compiled.MatchTimeout); // 00:00:01
| Method | Options | Timeout |
|---|---|---|
ToRegex() | The pattern's options | The pattern's timeout |
ToRegex(options) | The given options, which replace the pattern's | The pattern's timeout |
- Each call creates a new
Regex. The pattern's own matching methods use a private cached instance, andToRegex()does not return it. If you callToRegex()yourself, keep the result instead of calling it again. - An invalid regex or an invalid options combination throws here, immediately.
ToString()returns only the regex text, without options or timeout.- To have the regex text computed at build time, use the source generator (see Compile-Time Checks). The method it emits returns a cached
Regexwith the pattern's options plusCompiled, and the pattern's timeout.
Timeouts
A pattern with nested quantifiers can take exponential time on some inputs. On untrusted input, give the pattern a time budget with WithTimeout. When the budget runs out, the engine stops and throws RegexMatchTimeoutException instead of hanging.
// The analyzer reports FRX004 (ReDoS risk) for this nested quantifier
Pattern nested = Pattern.With.Group(Pattern.With.Letter.Repeat.OneOrMore).Repeat.OneOrMore.EndOfLine;
Pattern guarded = nested.WithTimeout(TimeSpan.FromMilliseconds(100));
string attack = new string('a', 40) + "!";
try
{
guarded.IsMatch(attack);
}
catch (RegexMatchTimeoutException exception)
{
Console.WriteLine($"Timed out after {exception.MatchTimeout.TotalMilliseconds} ms"); // Timed out after 100 ms
}
- The timeout travels with the pattern into every matching method on this page, into
ToRegex()andToRegex(options), and into the code the source generator emits. It survivesWithOptionsand extending the chain. Timeoutreturns the pattern's timeout. A new pattern hasRegex.InfiniteMatchTimeout, which means no timeout.- The exception's
PatternandInputproperties hold the regex text and the input. Do not logInputwhen it can contain personal data.
WithTimeout validates its argument:
| Value | Result |
|---|---|
| A positive time up to about 24 days | Accepted |
Regex.InfiniteMatchTimeout | Accepted. Removes a timeout set earlier |
| Zero, a negative time, or more than about 24 days | Throws ArgumentOutOfRangeException |
A pattern always passes its own timeout to Regex, even when it is infinite. The process-wide default set with AppDomain.CurrentDomain.SetData("REGEX_DEFAULT_MATCH_TIMEOUT", ...) therefore does not apply to patterns. Call WithTimeout on every pattern that runs on untrusted input.
A timeout limits the damage; it does not fix the pattern. To find and fix the risky part, see ReDoS Safety.
The non-backtracking engine
On .NET 8 and later, UseNonBacktracking() returns a copy of the pattern that runs on the linear-time engine. Its matching time grows linearly with the input length, so it is immune to catastrophic backtracking.
// nested is from the "Timeouts" example
Pattern linear = nested.UseNonBacktracking();
string longAttack = new string('a', 50_000) + "!";
Console.WriteLine(linear.Options); // NonBacktracking
Console.WriteLine(linear.IsMatch(longAttack)); // False
UseNonBacktracking()addsRegexOptions.NonBacktrackingto the existing options. It does not replace them.- Captures, named groups and replacement substitutions still work.
- A later
WithOptions(...)replaces all options and dropsNonBacktracking. CallUseNonBacktracking()last, or include the flag yourself. - The member is not available on
netstandard2.1.
The engine does not support every construct. The pattern is built without error; the exception comes when the pattern first runs (or when you call ToRegex()):
Pattern doubled = Pattern.With.NamedGroup("char", Pattern.With.Letter).Backreference("char");
Pattern linearDoubled = doubled.UseNonBacktracking(); // no error yet
try
{
linearDoubled.IsMatch("aa");
}
catch (NotSupportedException exception)
{
Console.WriteLine(exception.Message);
}
// prints: RegexOptions.NonBacktracking is not supported in conjunction with expressions containing: 'backreference (\ number)'.
| Construct | Exception at first use |
|---|---|
Backreferences (Backreference) | NotSupportedException |
| Lookaheads and lookbehinds | NotSupportedException |
Atomic groups, including Possessive quantifiers | NotSupportedException |
Conditionals (Conditional) | NotSupportedException |
RegexOptions.RightToLeft | ArgumentOutOfRangeException |
For untrusted input on .NET 8 and later, UseNonBacktracking() is the strongest guarantee when the pattern avoids these constructs. Otherwise use WithTimeout. The two can be combined.
Span APIs on modern .NET
On .NET 8 and later, patterns expose the allocation-free ReadOnlySpan<char> APIs of Regex. Use them on hot paths, or to search part of a string without calling Substring.
Pattern number = Pattern.With.Digit.Repeat.OneOrMore;
ReadOnlySpan<char> text = "order 17, order 204".AsSpan();
Console.WriteLine(number.IsMatch(text)); // True
Console.WriteLine(number.Count(text)); // 2
foreach (ValueMatch match in number.EnumerateMatches(text))
{
Console.WriteLine($"{text.Slice(match.Index, match.Length).ToString()} at {match.Index}");
}
// prints:
// 17 at 6
// 204 at 16
| Member | Returns | Available on |
|---|---|---|
IsMatch(ReadOnlySpan<char>) | bool | net8.0, net10.0 |
Count(ReadOnlySpan<char>) | int | net8.0, net10.0 |
EnumerateMatches(ReadOnlySpan<char>) | Regex.ValueMatchEnumerator of ValueMatch | net8.0, net10.0 |
EnumerateSplits(ReadOnlySpan<char>) | Regex.ValueSplitEnumerator of Range | net10.0 |
- A
ValueMatchhas onlyIndexandLength. It has noValueand no groups. Slice the input to get the text, and useMatchwhen you need groups. - Positions are relative to the span you pass, not to the string it came from.
- The enumerators are
ref structs. Use them in aforeach; you cannot store them in a field or use them across anawait.
Searching part of a string:
// number is from the previous example
string record = "id=17;qty=204;note=none";
ReadOnlySpan<char> quantity = record.AsSpan(6, 7); // "qty=204"
Console.WriteLine(number.IsMatch(quantity)); // True
foreach (ValueMatch match in number.EnumerateMatches(quantity))
{
Console.WriteLine(match.Index); // 4
}
Splitting without allocations
On .NET 10, EnumerateSplits gives the pieces as Range values instead of strings.
Pattern comma = Pattern.With.Literal(",");
string csv = "a,b,,c";
foreach (Range part in comma.EnumerateSplits(csv))
{
Console.WriteLine($"{part} -> \"{csv[part]}\"");
}
// prints:
// 0..1 -> "a"
// 2..3 -> "b"
// 4..4 -> ""
// 5..6 -> "c"
- Empty pieces are included, as with
Split. - Unlike
Split, captured groups are not added to the result. Only the pieces between the matches are returned.
The package ships builds for netstandard2.1, net8.0 and net10.0, and your project gets the closest one. A .NET 9 project uses the net8.0 build, so EnumerateSplits is not available there, even though .NET 9's Regex has it. Projects on .NET Core 3.x to .NET 7 use the netstandard2.1 build, which has none of the span APIs and no UseNonBacktracking().
Errors and edge cases
| Situation | What happens |
|---|---|
input is null | ArgumentNullException from every method (the parameter name is input) |
replacement or evaluator is null | ArgumentNullException |
startAt is below 0 or above input.Length | ArgumentOutOfRangeException |
Replace count below -1, or Split count below 0 | ArgumentOutOfRangeException |
| The regex text is invalid | An ArgumentException when the pattern first runs (or on ToRegex()). On .NET 7 and later it is the derived RegexParseException. Catch ArgumentException to handle every target |
| The options are an invalid combination | ArgumentOutOfRangeException when the pattern first runs |
| The timeout elapses | RegexMatchTimeoutException |
An empty pattern (Pattern.With, where IsEmpty is true) | Matches the empty string everywhere: IsMatch is true and Count("ab") is 3 |
An invalid regex usually comes from raw text added with RegEx(...). Building the pattern does not parse it; the first match does:
// regexText comes from your configuration, for example "(abc"
Pattern custom = Pattern.With.RegEx(regexText);
try
{
custom.IsMatch("abc");
}
catch (ArgumentException exception)
{
Console.WriteLine(exception.Message); // Invalid pattern '(abc' at offset 4. Not enough )'s.
}
When the regex text is a constant in your code, the analyzer reports it at compile time as error FRX003, so the program never runs with it. To check text you load at run time before you use it, see Validation and Debugging.
Performance and thread safety
- The
Regexis cached per pattern instance. The first call to a matching method builds it; later calls reuse it. Store patterns you use often instatic readonlyfields, so the cache is built once. - Equal patterns do not share a cache. Two patterns built the same way are equal (
Equalscompares the regex text, the options and the timeout), but each instance builds its ownRegex. Note that==is not overloaded: it still compares references, soPattern.With.Digit == Pattern.With.DigitisfalsewhileEqualsreturnstrue. - Some calls skip the cache.
IsMatch(input, options),ToRegex()andToRegex(options)build a newRegexevery time. - Patterns are safe to share between threads. A pattern is immutable, and
Regexmatching methods are thread-safe. If two threads trigger the first use at the same moment, both may build aRegexand one is kept; the result is the same. - Use
Compiledfor hot patterns. Because the cache keeps the compiledRegex, the extra start-up cost is paid once per pattern instance. - Use the span APIs on hot paths on .NET 8 and later:
CountandEnumerateMatchesdo not allocateMatchobjects. - Prefer
CountoverMatches(...).Countwhen you only need the number.
public static class OrderPatterns
{
public static readonly Pattern OrderNumber = Pattern.With
.StartOfInput
.Literal("ORD-")
.Digit.Repeat.Times(6)
.EndOfInput
.WithOptions(RegexOptions.Compiled)
.WithTimeout(TimeSpan.FromMilliseconds(100));
}
Every call then reuses the one compiled Regex:
Console.WriteLine(OrderPatterns.OrderNumber.IsMatch("ORD-004211")); // True
Console.WriteLine(OrderPatterns.OrderNumber.IsMatch("ORD-42")); // False
Framework availability
| Member | netstandard2.1 | net8.0 | net10.0 |
|---|---|---|---|
IsMatch(string), IsMatch(string, RegexOptions) | Yes | Yes | Yes |
Match(string), Match(string, int), Matches(string) | Yes | Yes | Yes |
Count(string) | Yes | Yes | Yes |
Replace (all overloads), Split (both overloads) | Yes | Yes | Yes |
ToRegex(), ToRegex(RegexOptions) | Yes | Yes | Yes |
WithOptions, WithTimeout, Options, Timeout | Yes | Yes | Yes |
UseNonBacktracking() | No | Yes | Yes |
IsMatch(ReadOnlySpan<char>), Count(ReadOnlySpan<char>), EnumerateMatches | No | Yes | Yes |
EnumerateSplits | No | No | Yes |
To run these methods over a TextReader or a stream, see Streaming. To replace matches with masks, see PII Redaction. To see step by step why an input matches or fails, see Match Tracing, and to measure how much a match backtracks, see Backtracking Profiler.