Language Facts
Language facts are things that are true for every string a pattern accepts: the shortest and longest accepted length, the text every accepted string starts with and ends with, and whether the empty string (or nothing at all) is accepted. You get them with one call, GetLanguageFacts(), and the library proves them from the pattern's minimal automaton instead of guessing from the regex text.
Because the facts are sound, you can act on them without ever losing a match. Use this page when you need to:
- know the shortest and longest string a pattern can accept, for example to size a database column or reject input early,
- find a literal prefix that lets a B-tree index or a sorted list narrow a search,
- build a cheap prefilter that throws away most candidates before the regex runs,
- understand why a pattern ending in
$has no required suffix, or why a case-insensitive pattern has no required prefix.
GetLanguageFacts, LanguageFacts and MatchScope live in the Masterly.FluentRegex namespace and work on every target the package supports: .NET Standard 2.1, .NET 8 and .NET 10. The Entity Framework Core index hints need the Masterly.FluentRegex.EntityFrameworkCore package, which targets .NET 8 and .NET 10.
| Member | Kind | What it gives you |
|---|---|---|
pattern.GetLanguageFacts(scope = MatchScope.FullString) | Method on Pattern | A LanguageFacts object for the chosen scope. |
LanguageFacts.Scope | MatchScope | The set of strings the facts describe. |
LanguageFacts.MinLength | int | The length of the shortest accepted string. |
LanguageFacts.MaxLength | int? | The length of the longest accepted string, or null when there is no upper bound. |
LanguageFacts.RequiredPrefix | string | The text every accepted string starts with. Empty when nothing is required. |
LanguageFacts.RequiredSuffix | string | The text every accepted string ends with. Empty when nothing is required. |
LanguageFacts.AcceptsEmpty | bool | true when the empty string is accepted. |
LanguageFacts.IsEmptyLanguage | bool | true when no string at all is accepted. |
LanguageFacts.IsExact | bool | true when the facts come from the automaton; false when they are a safe approximation. |
LanguageFacts.ToString() | Method | A one-line summary of all the facts. |
LanguageFacts is a sealed, immutable class. You cannot create one yourself; only GetLanguageFacts returns it.
Reading the facts of a patternโ
Call GetLanguageFacts() on any pattern. Without an argument it describes the strings the pattern matches entirely (the FullString scope, explained in Choosing a scope).
using System;
using Masterly.FluentRegex;
LanguageFacts facts = Pattern.FromRegex(@"^INV-\d{4}-\d{4}\z").GetLanguageFacts();
int minLength = facts.MinLength; // 13
int? maxLength = facts.MaxLength; // 13
string prefix = facts.RequiredPrefix; // "INV-"
string suffix = facts.RequiredSuffix; // "" (the last digits vary)
bool acceptsEmpty = facts.AcceptsEmpty; // false
bool matchesNothing = facts.IsEmptyLanguage; // false
bool isExact = facts.IsExact; // true
MatchScope scope = facts.Scope; // FullString
- Every invoice number is exactly 13 characters long, so
MinLengthandMaxLengthare both 13. - Every accepted string starts with
INV-. No suffix is required, because the string ends with a digit that can be any digit. IsExactistrue: the pattern is regular, so these are the real facts, not an estimate. See Exact and approximate facts.GetLanguageFactsnever throws for a valid pattern. The only exceptions areArgumentOutOfRangeExceptionfor aMatchScopevalue that is not defined, andInvalidOperationExceptionwhen the pattern text is not valid regex syntax (for example a rawRegEx(...)piece with an unclosed group, which the compile-time checks already flag).
Facts of a fluent patternโ
Facts work the same way on a pattern you build with the fluent API. They are computed from the regex the chain produces.
Pattern sku = Pattern.With
.StartOfInput
.Literal("SKU-")
.Digit.Repeat.Times(3, 6)
.Literal("-EU")
.EndOfInput;
LanguageFacts facts = sku.GetLanguageFacts();
Console.WriteLine(sku); // prints: \ASKU-\d{3,6}-EU\z
Console.WriteLine(facts); // prints: FullString: length 10..13, prefix "SKU-", suffix "-EU", accepts empty: false (exact)
- The length runs from 10 (
SKU-+ 3 digits +-EU) to 13 (6 digits). StartOfInputandEndOfInput(\Aand\z) pin both ends, so the facts are the same in both scopes.
The summary lineโ
ToString() puts all the facts on one line. It is handy in logs, in test failure messages and when you explore a pattern.
FullString: length 13..13, prefix "INV-", suffix "", accepts empty: false (exact)
- The line starts with the scope, then
length MinLength..MaxLength. A missing upper bound prints asunbounded. - The prefix and suffix are quoted. Quotes, backslashes,
\n,\r,\t, control characters, white space other than the plain space, and surrogates are escaped as\uXXXX, so the emoji๐prints as\uD83D\uDE00. The properties themselves hold the real characters. - It ends with
(exact)or(approximate). - A pattern that matches nothing prints
FullString: matches nothing (exact)instead.
Choosing a scopeโ
The same pattern describes two different sets of strings, depending on how you use it. GetLanguageFacts takes a MatchScope to say which one you mean.
MatchScope | The strings described | Use it when |
|---|---|---|
FullString (default) | Strings the pattern matches entirely, as if it were wrapped in \A(?:...)\z. An explicit $ still accepts one final newline. This is the scope pattern algebra uses. | You validate whole values, compare patterns, or reason about the language itself. |
Substring | Strings for which IsMatch returns true: strings that contain a match anywhere. The padding is dropped on a side the pattern anchors. | You filter rows or lines with IsMatch, or build a prefilter, an index lookup or a SQL condition. |
Pattern startsWithAbc = Pattern.FromRegex(@"^abc");
LanguageFacts full = startsWithAbc.GetLanguageFacts(); // MatchScope.FullString
LanguageFacts search = startsWithAbc.GetLanguageFacts(MatchScope.Substring);
Console.WriteLine(full); // prints: FullString: length 3..3, prefix "abc", suffix "abc", accepts empty: false (exact)
Console.WriteLine(search); // prints: Substring: length 3..unbounded, prefix "abc", suffix "", accepts empty: false (exact)
- In
FullStringscope,^abcaccepts onlyabc. - In
Substringscope,IsMatchalso acceptsabcdeforabc and more. The prefix survives because^pins the start, but the end is open, so there is no suffix and no upper bound.
Pick the scope that matches how you run the pattern. If you prefilter strings that you then test with IsMatch, use MatchScope.Substring. The FullString facts of an unanchored pattern such as abc say "exactly 3 characters", which would wrongly reject xabcx, a string IsMatch accepts.
How anchors change the factsโ
In Substring scope, only an anchor keeps an end fixed. ^ and \A pin the start. \z pins the end exactly. $ and \Z pin the end too, but they also accept one final newline. With RegexOptions.Multiline, ^ and $ match at every line, so they do not pin anything, and the facts become approximate (see What makes facts approximate).
| Pattern | FullString facts | Substring facts |
|---|---|---|
abc | length 3..3, prefix abc, suffix abc | length 3..unbounded, no prefix, no suffix |
^abc | length 3..3, prefix abc, suffix abc | length 3..unbounded, prefix abc, no suffix |
abc\z | length 3..3, prefix abc, suffix abc | length 3..unbounded, no prefix, suffix abc |
abc$ | length 3..4, prefix abc, no suffix | length 3..unbounded, no prefix, no suffix |
abc\Z | length 3..4, prefix abc, no suffix | length 3..unbounded, no prefix, no suffix |
^abc$ | length 3..4, prefix abc, no suffix | length 3..4, prefix abc, no suffix |
^abc\z | length 3..3, prefix abc, suffix abc | length 3..3, prefix abc, suffix abc |
\Aabc\z | length 3..3, prefix abc, suffix abc | length 3..3, prefix abc, suffix abc |
Every row is exact. A pattern anchored at both ends with \z has the same facts in both scopes.
Requiring a suffix at the very endโ
A pattern that ends in $ or \Z never has a required suffix, because the string may end with the newline those anchors accept. Use \z (or EndOfInput in the fluent API) when you mean the very end.
LanguageFacts strictEnd = Pattern.FromRegex(@"xyz\z").GetLanguageFacts(MatchScope.Substring);
LanguageFacts lenientEnd = Pattern.FromRegex(@"xyz$").GetLanguageFacts(MatchScope.Substring);
string strictSuffix = strictEnd.RequiredSuffix; // "xyz"
string lenientSuffix = lenientEnd.RequiredSuffix; // "" because "xyz\n" also matches
bool trailingNewline = Pattern.FromRegex(@"xyz$").IsMatch("xyz\n"); // true
This is not a bug in the facts. xyz$ really accepts "xyz\n", which does not end with xyz. The same newline is why ^abc$ has a maximum length of 4, not 3.
In free-spacing mode (RegexOptions.IgnorePatternWhitespace, or an inline (?x) still active at the end), a trailing # comment does not swallow the Substring padding. ^INV-[0-9]{4} # invoice number has length 8..unbounded in Substring scope, the same as without the comment.
Length boundsโ
MinLength is the length of the shortest accepted string. MaxLength is the length of the longest one, or null when accepted strings can be as long as you like.
LanguageFacts withDollar = Pattern.FromRegex(@"^\d{3}-\d{4}$").GetLanguageFacts();
LanguageFacts withZ = Pattern.FromRegex(@"^\d{3}-\d{4}\z").GetLanguageFacts();
LanguageFacts open = Pattern.FromRegex(@"^[0-9]+(?:\.[0-9]+)?\z").GetLanguageFacts();
int? dollarMax = withDollar.MaxLength; // 9: "$" also accepts one final "\n"
int? zMax = withZ.MaxLength; // 8
int? openMax = open.MaxLength; // null: no upper bound
int openMin = open.MinLength; // 1
- Test
MaxLengthwith a pattern such asfacts.MaxLength is int max. It isnullwhenever a+,*or{n,}can repeat. MinLengthis0when the pattern accepts the empty string, and also when it accepts nothing at all.- Leading lookaheads are modeled exactly.
^(?=.*\d).{8,}$hasMinLength8 and is still exact. InSubstringscope this needs a start anchor:(?=a)abcis exact inFullStringscope but approximate inSubstringscope, where the lookahead no longer sits at the start.
More exact examples, all in FullString scope:
| Pattern | Length | Prefix | Suffix |
|---|---|---|---|
^ab?ba\z | 3..4 | ab | ba |
^colou?r\z | 5..6 | colo | r |
^a\d*b\z | 2..unbounded | a | b |
^(?:abc)+\z | 3..unbounded | abc | abc |
^\d{4}\z | 4..4 | none | none |
^(?=.*\d).{8,}$ | 8..unbounded | none | none |
CommonPatterns.Email(PatternStrictness.Strict) | 6..255 | none | none |
CommonPatterns.Url() | 10..unbounded | http | none |
CommonPatterns.ZipCode() | 5..11 | none | none |
Lengths count UTF-16 code unitsโ
MinLength and MaxLength count UTF-16 code units, the same unit as string.Length in .NET. An emoji outside the Basic Multilingual Plane counts as 2.
Pattern twoCharacters = Pattern.FromRegex(@"^..\z");
int minLength = twoCharacters.GetLanguageFacts().MinLength; // 2
bool oneEmoji = twoCharacters.IsMatch("๐"); // true: one code point, two UTF-16 units
This matters when you move the bounds into a database:
MaxLengthis safe with character-counting functions. A string never has more code points than UTF-16 units, so a character count<= MaxLengthnever drops a matching row. That holds for PostgreSQL and SQLitelength, MySQL and MariaDBCHAR_LENGTH, and SQL ServerLEN(which also ignores trailing blanks). Do not use MySQLLENGTH: it counts bytes, which can exceedMaxLengthfor a matching row.MinLengthis not safe on databases that count code points.^..\zhasMinLength2 but accepts a single emoji, which PostgreSQL counts as length 1. Do not turnMinLengthintolength(column) >= MinLengththere. In .NET code, comparing it withstring.Lengthis always safe.
Required prefix and suffixโ
RequiredPrefix is the longest text that every accepted string starts with. RequiredSuffix is the longest text that every accepted string ends with. Both are empty when nothing is required. They are found by walking the automaton: the prefix follows the start state forward while exactly one character can come next, and the suffix walks backward from the accepting states the same way.
| Pattern | Prefix | Suffix | Why |
|---|---|---|---|
^(?:ab|ac)\z | a | none | Both branches start with a, then differ. |
^(?:PUT|PATCH)\z | P | none | Only the first letter is shared. |
^(?:GET|POST|PUT)\z | none | T | The branches start differently but all end in T. |
\.(?:jpg|png)\z | . | g | jpg and png share only their last letter. In Substring scope the prefix is gone, the suffix stays. |
^https?://example\.com/ | http | ://example.com/ | The optional s stops the prefix. In Substring scope the suffix is gone, because the end is open. |
^[a-z]+\.log\z | none | .log | A class of many characters stops the prefix immediately. |
A character class with more than one member always ends the walk, even a small one such as [0-9] or \d. Only a single, fixed character can be required.
Case-insensitive patternsโ
Under RegexOptions.IgnoreCase (or (?i)), a letter also matches its other case, so it is no longer one fixed character. Only characters without case variants, such as digits and punctuation, can be required, and the walk stops at the first letter.
using System.Text.RegularExpressions;
LanguageFacts caseless = Pattern.FromRegex(@"^ab-12\z", RegexOptions.IgnoreCase).GetLanguageFacts();
LanguageFacts hashFirst = Pattern.FromRegex(@"^#\d{3}-x\z", RegexOptions.IgnoreCase).GetLanguageFacts();
string caselessPrefix = caseless.RequiredPrefix; // "" because "a" also matches "A"
string caselessSuffix = caseless.RequiredSuffix; // "-12"
string hashPrefix = hashFirst.RequiredPrefix; // "#": the walk stops at the digits
If you need an index-friendly prefix, keep the fixed part of the pattern case-sensitive and apply case-insensitivity only to the part that needs it, with an inline (?i:...) group:
LanguageFacts scoped = Pattern.FromRegex(@"^INV-(?i:[a-z]+)\z").GetLanguageFacts();
string scopedPrefix = scoped.RequiredPrefix; // "INV-"
Surrogate pairs and emojiโ
The prefix and suffix always contain whole code points. The prefix never ends with the first half of a surrogate pair, and the suffix never starts with the second half. This keeps them valid for databases that store UTF-8 or compare code points.
LanguageFacts shared = Pattern.FromRegex("^(?:๐|๐)x\\z").GetLanguageFacts();
LanguageFacts whole = Pattern.FromRegex("^๐ab\\z").GetLanguageFacts();
string sharedPrefix = shared.RequiredPrefix; // "": the branches share only half of a surrogate pair
string wholePrefix = whole.RequiredPrefix; // "๐ab"
int wholeLength = wholePrefix.Length; // 4: the emoji is two UTF-16 units
Console.WriteLine(whole); // prints: FullString: length 4..4, prefix "\uD83D\uDE00ab", suffix "\uD83D\uDE00ab", accepts empty: false (exact)
๐and๐share their high surrogate, but half a character is not a usable prefix, soRequiredPrefixis empty.ToString()escapes surrogates, so the summary line shows the emoji as\uD83D\uDE00.RequiredPrefixitself holds the real emoji.
Empty strings and empty languagesโ
Two flags answer the edge cases: AcceptsEmpty says whether "" is accepted, and IsEmptyLanguage says whether anything is accepted at all.
LanguageFacts optional = Pattern.FromRegex(@"^x*\z").GetLanguageFacts();
LanguageFacts impossible = Pattern.FromRegex(@"^abc[^\s\S]\z").GetLanguageFacts();
bool optionalAcceptsEmpty = optional.AcceptsEmpty; // true
int optionalMin = optional.MinLength; // 0
bool nothing = impossible.IsEmptyLanguage; // true
int? impossibleMax = impossible.MaxLength; // 0
Console.WriteLine(impossible); // prints: FullString: matches nothing (exact)
[^\s\S]is a class that contains no character, so^abc[^\s\S]\zcan never match.- When
IsEmptyLanguageistrue,MinLengthandMaxLengthare both0, the prefix and suffix are empty, andAcceptsEmptyisfalse. CheckIsEmptyLanguagefirst; the other values carry no meaning then. - An empty-language result is a bug signal in most applications. It usually means two parts of the pattern contradict each other. Pattern algebra can show you why.
- The empty pattern
""accepts only""inFullStringscope (length 0..0) and every string inSubstringscope (length 0..unbounded).^$accepts""and"\n", so its length is 0..1.
Exact and approximate factsโ
When the pattern is regular, the library builds its minimal automaton and reads the facts off it. Those facts are exact, and IsExact is true.
Some patterns cannot be turned into an automaton. For those, GetLanguageFacts does not throw. It returns a safe approximation read from the regex syntax tree, with IsExact set to false.
LanguageFacts repeated = Pattern.FromRegex(@"^abc(\w)\1def\z").GetLanguageFacts();
bool isExact = repeated.IsExact; // false: a backreference is not regular
int minLength = repeated.MinLength; // 7 (a lower bound)
int? maxLength = repeated.MaxLength; // null: approximations never give an upper bound
string prefix = repeated.RequiredPrefix; // "abc"
string suffix = repeated.RequiredSuffix; // "def"
Console.WriteLine(repeated); // prints: FullString: length 7..unbounded, prefix "abc", suffix "def", accepts empty: false (approximate)
What makes facts approximateโ
| Construct | Example | Notes |
|---|---|---|
| Backreferences | (\w)\1 | Never regular. |
| Lookbehinds, and lookaheads anywhere but the very start | ab(?<=b)cd, ^(?<=x)abc\z, ^a{3}(?=b)b\z | A lookahead at the very start, such as ^(?=.*\d).{8,}$, stays exact. |
| Word boundaries | \bcat\b | |
| Conditionals and balancing groups | (a)?(?(1)a|b), ^(?<o>a)(?<-o>b)\z | |
| Multiline anchors | (?m)^abc$ | |
RegexOptions.RightToLeft | Even ^abc\z is approximate. | |
| Automaton size limits | ^a{1001}\z, ^ab[ab]*a[ab]{18}\z | A repeat bound above 1,000, or an automaton above 50,000 DFA states (200,000 NFA states). |
How approximate facts are computedโ
RequiredPrefixis the run of literal characters at the start of the pattern. Zero-width constructs such as lookarounds and\bare skipped because they consume nothing. A quantifier contributes its mandatory copies, and an alternation contributes what all its branches share.RequiredSuffixis the same walk from the end.- The prefix is reported only when the scope or an anchor pins the start, and the suffix only when the scope or an anchor pins the end. Walking back from the end, the suffix stops at any
$or\Z(withoutMultiline), even one inside a group or quantifier:(a)\1(?:abc$){1}requires no suffix. - Under
IgnoreCase, a letter that has case variants stops the walk, as for exact facts. MinLengthis a lower bound summed over the tree. A backreference counts as 0 because the group it repeats may be empty.MaxLengthis alwaysnull.AcceptsEmptyis not estimated. The library asks the .NET engine whether it accepts""in the requested scope (a timeout on that check counts astrue).
| Pattern | Scope | Approximate facts |
|---|---|---|
abc(\w)\1def | FullString | length 7..unbounded, prefix abc, suffix def |
abc(\w)\1def | Substring | length 7..unbounded, no prefix, no suffix |
^ab\bc(?<=c)d$ | FullString | length 4..unbounded, prefix abcd, no suffix |
\bcat\b | Substring | length 3..unbounded, no prefix, no suffix |
(?m)^abc$ | FullString | length 3..unbounded, prefix abc, suffix abc |
(?m)^abc$ | Substring | length 3..unbounded, no prefix, no suffix |
^ab[ab]*a[ab]{18}\z | FullString | length 21..unbounded, prefix ab, no suffix |
Acting on approximate factsโ
Approximate facts are still sound. The real required prefix and suffix can only be longer, and the real minimum length only higher, so every check this page describes stays correct. You only lose precision: no upper length bound, and possibly a shorter prefix than the truth.
If a prefilter or index matters for performance, check IsExact in a unit test. A refactoring that adds a backreference or a \b silently turns an exact MaxLength into null.
How exact facts are computedโ
You do not need this to use the facts, but it explains why they can be trusted.
- The scoped pattern is compiled to a minimal DFA over exact character sets, the same automaton pattern algebra uses for
Pattern.Compare. MinLengthis a breadth-first search from the start state to the nearest accepting state.MaxLengthis the longest path to an accepting state, ornullwhen the automaton has a cycle. Every state of a minimal DFA can reach an accepting state, so any cycle makes the language unbounded.RequiredPrefixfollows the forced path from the start state: while the state is not accepting and exactly one single-character transition leads on, that character begins every accepted string.RequiredSuffixdoes the same walk backward from the set of accepting states. The reversed automaton is simulated lazily as sets of states, so it is never built in full.- The prefix and suffix are cut back to whole code points.
The test suite generates matching strings for every CommonPatterns member and a set of hand-picked patterns, and checks that each one respects the FullString facts. It also surrounds those strings with extra text and checks them against the Substring facts.
Using facts as a prefilterโ
A regex check is cheap, but a length check and a StartsWith are cheaper. When you test many strings, let the facts throw away the strings that cannot match, and run the regex only on the rest.
Pattern invoice = Pattern.FromRegex(@"^INV-\d{4}-\d{4}\z");
LanguageFacts facts = invoice.GetLanguageFacts(MatchScope.Substring);
bool CouldMatch(string candidate) =>
candidate.Length >= facts.MinLength
&& (facts.MaxLength is not int maxLength || candidate.Length <= maxLength)
&& candidate.StartsWith(facts.RequiredPrefix, StringComparison.Ordinal)
&& candidate.EndsWith(facts.RequiredSuffix, StringComparison.Ordinal);
string[] candidates = { "INV-2024-0001", "PO-2024-0001", "INV-2024-00001", "INV-2024-ABCD", "inv-2024-0001" };
foreach (string candidate in candidates)
{
Console.WriteLine($"{candidate}: prefilter {CouldMatch(candidate)}, match {invoice.IsMatch(candidate)}");
}
// prints:
// INV-2024-0001: prefilter True, match True
// PO-2024-0001: prefilter False, match False
// INV-2024-00001: prefilter False, match False
// INV-2024-ABCD: prefilter True, match False
// inv-2024-0001: prefilter False, match False
- The prefilter never says
Falsefor a string the pattern accepts. It may sayTruefor a string the pattern rejects (INV-2024-ABCD), so you still runIsMatchon what passes. - Use
MatchScope.Substringbecause the final check isIsMatch. - Compare with
StringComparison.Ordinal. A culture-awareStartsWithcan treat different strings as equal, or equal strings as different, and break the guarantee. - Compute the facts once and reuse them. See Performance.
Narrowing a sorted indexโ
A required prefix turns a full scan into a range scan. On a list sorted with the ordinal comparer, every string that starts with the prefix sits in one block, so you can binary-search to the start of the block and stop at its end.
using System.Collections.Generic;
Pattern invoice = Pattern.FromRegex(@"^INV-\d{4}-\d{4}\z");
string prefix = invoice.GetLanguageFacts(MatchScope.Substring).RequiredPrefix; // "INV-"
// numbers comes from your application, sorted once with the ordinal comparer
List<string> numbers = new List<string> { "CN-2024-0003", "INV-2024-0001", "INV-2025-0420", "INV-2025-04201", "PO-0007" };
int start = numbers.BinarySearch(prefix, StringComparer.Ordinal);
if (start < 0)
{
start = ~start;
}
List<string> matches = new List<string>();
for (int i = start; i < numbers.Count && numbers[i].StartsWith(prefix, StringComparison.Ordinal); i++)
{
if (invoice.IsMatch(numbers[i]))
{
matches.Add(numbers[i]);
}
}
Console.WriteLine(string.Join(", ", matches)); // prints: INV-2024-0001, INV-2025-0420
- The loop visits only the three
INV-entries, not the whole list.INV-2025-04201is in the range but fails the regex. - The same idea works for a database B-tree index:
column LIKE 'INV-%'is a range scan on most engines. Escape%,_and your escape character in the prefix before you put it in aLIKE. - When
RequiredPrefixis empty, there is no range to narrow. Fall back to the length bound or a full scan.
Index hints in Entity Framework Coreโ
You do not have to write the database side by hand. The Masterly.FluentRegex.EntityFrameworkCore package reads the Substring facts of every pattern known when the query is compiled and adds them to the SQL as extra conditions. These index hints are on by default; UseIndexHints(false) turns them off.
using System;
using System.Linq;
using Masterly.FluentRegex;
using Masterly.FluentRegex.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
public static class Patterns
{
public static readonly Pattern Invoice = Pattern.FromRegex(@"^INV-[0-9]{4}-[0-9]{4}\z");
}
public sealed class Invoice
{
public int Id { get; set; }
public string Number { get; set; } = string.Empty;
}
public sealed class ShopContext : DbContext
{
public DbSet<Invoice> Invoices => Set<Invoice>();
protected override void OnConfiguring(DbContextOptionsBuilder options) => options
.UseSqlite("Data Source=:memory:")
.UseFluentRegex(fluent => fluent.UseDialect(SqlDialect.Sqlite).UseIndexHints());
}
Query the invoices and print the SQL that EF Core sends:
using ShopContext db = new ShopContext();
IQueryable<Invoice> query = db.Invoices.Where(invoice => Patterns.Invoice.IsMatch(invoice.Number));
Console.WriteLine(query.ToQueryString());
The generated SQL on SQLite:
SELECT "i"."Id", "i"."Number"
FROM "Invoices" AS "i"
WHERE "i"."Number" REGEXP '^INV-[0-9]{4}-[0-9]{4}\z' AND "i"."Number" LIKE 'INV-%' ESCAPE '\' AND length("i"."Number") <= 13
LIKE 'INV-%'comes fromRequiredPrefixand lets an index onNumbernarrow the scan.length(...) <= 13comes fromMaxLength. The regex still decides; the hints never change the result.- Only the upper length bound is emitted, never
MinLength, for the code-point reason in Lengths count UTF-16 code units. - The hints are added for the dialects that translate
IsMatchto a regex: PostgreSQL, MySQL, MariaDB, SQLite andSqlDialect.SqlServer2025. On SQL Server 2025 the prefixLIKEis added only when the column is declared binary withUseLikeCollation(LikeCollation.Binary), because linguistic collations do not compareLIKEcharacter by character; theLENbound is always added.SqlDialect.SqlServerhas no regex support, so thereIsMatchbecomes aLIKEtranslation instead of a regex with hints. - A pattern held in a local variable becomes a query parameter, so no facts are known when the query is compiled, and no hints are added.
- The package caches the facts per pattern, so a query does not pay the cost on every execution.
See Entity Framework Core for the full integration, and SQL LIKE for ToLike, which also uses language facts to build a proven prefix%suffix LIKE when it cannot translate a pattern exactly.
From the command lineโ
The command-line tool prints the same summary line with fluentregex facts. Use --scope full (the default) or --scope substring, --options for a comma-separated list of RegexOptions, and --ascii-digits to make \d match only 0-9.
fluentregex facts "^INV-\d{4}-\d{4}\z"
fluentregex facts "^abc" --scope substring
fluentregex facts "abc" --options IgnoreCase
fluentregex facts "^(\w)\1\z"
FullString: length 13..13, prefix "INV-", suffix "", accepts empty: false (exact)
Substring: length 3..unbounded, prefix "abc", suffix "", accepts empty: false (exact)
FullString: length 3..3, prefix "", suffix "", accepts empty: false (exact)
FullString: length 1..unbounded, prefix "", suffix "", accepts empty: false (approximate)
The tool also accepts fullstring, full-string and search as scope names. An unknown scope prints error: Unknown --scope '...'. Use full or substring. and exits with code 2, as does a missing regex argument.
Performanceโ
GetLanguageFacts builds and minimizes an automaton, so it costs far more than one IsMatch. Most patterns take milliseconds, but a large one can take much longer: on a development machine, the facts of CommonPatterns.Email(PatternStrictness.Strict) took about 1.7 seconds per call, and CommonPatterns.Url() about 10 milliseconds.
- The result is not cached on the
Pattern. Every call computes it again. - Compute the facts once, at startup or in a
static readonlyfield, and reuse theLanguageFactsobject. It is immutable and safe to share across threads. - The work is bounded. A pattern that would need more than 50,000 DFA states falls back to approximate facts instead of running away.
Common pitfallsโ
- Using
FullStringfacts to filterIsMatchinput. For an unanchored pattern they are far too strict. UseMatchScope.Substringwhenever the real check isIsMatch. - Expecting a suffix from a pattern that ends in
$.$accepts a final newline, so no suffix can be required andMaxLengthgrows by one. End with\z(EndOfInput). - Expecting a prefix under
IgnoreCase. Only caseless characters can be required, and the walk stops at the first letter. - Turning
MinLengthinto a SQL lower bound. SQL length functions count code points (andLENdrops trailing blanks), so the bound can exclude matches.MaxLengthis safe;MinLengthis not. - Reading
MaxLength == nullas "approximate". It also happens for exact facts whenever the pattern can repeat without limit. CheckIsExactfor exactness. - Ignoring
IsEmptyLanguage. When it istrue,MinLengthandMaxLengthare both0. A check such aslength <= MaxLengthwould then accept only empty strings, which the pattern also rejects. - Using a culture-aware comparison with the prefix. Use
StringComparison.OrdinalandStringComparer.Ordinal, as the facts compare UTF-16 code units.
Related pagesโ
- Pattern Algebra proves two patterns equivalent, or shows a string that tells them apart, using the same automata.
- Optimizer rewrites patterns with verified, semantics-preserving changes.
- SQL LIKE and SQL Dialects move patterns into the database.
- Entity Framework Core adds the index hints from this page to LINQ queries.