Skip to main content

Fuzzy Matching and Natural Sort

These helpers deal with text that people type and read. The fuzzy methods answer "did you mean ...?" when a command, option, enum value or setting name has a typo. The natural-sort methods put "file2" before "file10", so lists of file names, invoice numbers and versions come out in the order a person expects.

At a glance​

MethodWhat it does
LevenshteinDistanceCounts the single-character edits between two strings: "kitten" to "sitting" is 3.
SimilarityScores two strings from 0.0 (nothing alike) to 1.0 (identical).
ClosestMatchPicks the candidate string closest to what the user typed, or null.
ToClosestEnum<T>Typo-tolerant enum parsing by member name or [Description] text.
GetClosestKeyReturns the dictionary key closest to a mistyped key.
GetClosestReturns the value under the closest key.
NaturalStringComparerIComparer<string> and IEqualityComparer<string> that compare digit runs by value.
StringComparer.Natural / NaturalIgnoreCaseThe same two comparers, reachable from StringComparer.
OrderByNaturalSorts strings, or items by a string key, in natural order ignoring case.
OrderByNaturalDescendingThe same, largest first.

The string methods and NaturalStringComparer live in the System namespace, and the sequence and dictionary methods in System.Collections.Generic, so none of them needs an extra using.

Measuring similarity​

Both methods compare char by char (UTF-16 code units). They are the building blocks for the "did you mean" helpers further down; most code calls those instead.

LevenshteinDistance​

LevenshteinDistance(string other, bool ignoreCase = false) returns the smallest number of single-character inserts, deletes and replacements that turn one string into the other. Equal strings return 0. Use it when you need a raw edit count, for example "accept at most one typo".

Count edits
int classic = "kitten".LevenshteinDistance("sitting"); // → 3
int reversed = "sitting".LevenshteinDistance("kitten"); // → 3
int same = "invoice".LevenshteinDistance("invoice"); // → 0
int fromEmpty = "".LevenshteinDistance("abc"); // → 3
int caseCounts = "ABC".LevenshteinDistance("abc"); // → 3
int caseIgnored = "ABC".LevenshteinDistance("abd", ignoreCase: true); // → 1
Allow one typo in a coupon code
string typedCode = "SPRNG24";
string validCode = "SPRING24";

bool closeEnough = typedCode.LevenshteinDistance(validCode, ignoreCase: true) <= 1; // → true
  • A null receiver or a null other throws ArgumentNullException (ParamName is "str" or "other").
  • ignoreCase: true upper-cases both strings with ToUpperInvariant, so the result does not depend on the current culture.
  • Swapping two neighbouring letters costs 2 (two replacements), not 1: "teh".LevenshteinDistance("the") is 2. This is plain Levenshtein, not Damerau-Levenshtein.
  • The method keeps two rows of the edit matrix, so memory grows with the shorter string. Time grows with the product of the two lengths.

Similarity​

Similarity(string other, bool ignoreCase = false) turns the edit distance into a score between 0.0 and 1.0: 1.0 - distance / length of the longer string. A score is easier to compare across words of different lengths than a raw edit count.

Score two strings
double spelling = "color".Similarity("colour"); // → 0.8333333333333334
double identical = "invoice".Similarity("invoice"); // → 1.0
double unrelated = "abc".Similarity("xyz"); // → 0.0
double againstEmpty = "abc".Similarity(""); // → 0.0
double bothEmpty = "".Similarity(""); // → 1.0
double anyCase = "HELLO".Similarity("hello", ignoreCase: true); // → 1.0
  • A null receiver or a null other throws ArgumentNullException.
  • Two empty strings score 1.0. An empty string against a non-empty one scores 0.0.
  • Short strings lose a lot per edit. One typo in a four-letter word drops the score to 0.75; one typo in a twelve-letter word drops it only to about 0.92.

"Did you mean ...?"​

ClosestMatch, ToClosestEnum, GetClosestKey and GetClosest share one set of rules:

  • Scoring is Similarity with ignoreCase: true.
  • The default threshold is 0.5. A candidate that scores exactly 0.5 still counts as a match.
  • Every method has an overload that takes double minSimilarity. It must be between 0.0 and 1.0, or the method throws ArgumentOutOfRangeException (ParamName is "minSimilarity").
  • When two candidates have the same best score, the earliest one wins: the first in the sequence, the enum member that Enum.GetValues lists first (sorted by unsigned value, so the first declared one under default numbering), the first dictionary entry.
  • No match returns null (or default for GetClosest). The methods never throw for "no match".

ClosestMatch​

ClosestMatch(string input) and ClosestMatch(string input, double minSimilarity) run on any IEnumerable<string> of known values and return the one most similar to input, ignoring case. Use it for command names, file names, product codes or any list of valid choices.

Suggest a command
string[] commands = { "start", "stop", "status" };

string? typo = commands.ClosestMatch("statsu"); // → "status"
string? keepsCandidateCase = new[] { "Deploy", "STATUS" }.ClosestMatch("status"); // → "STATUS"
string? nothingClose = new[] { "build", "test" }.ClosestMatch("xyzzy"); // → null
string? empty = Array.Empty<string>().ClosestMatch("status"); // → null
Thresholds and ties
string[] codes = { "abcd" };

string? atThreshold = codes.ClosestMatch("abxy"); // → "abcd" (scores exactly 0.5)
string? strict = codes.ClosestMatch("abxy", 0.9); // → null
string? tie = new[] { "abcx", "abcy" }.ClosestMatch("abcz"); // → "abcx" (earliest wins)
string? anything = new[] { "xyz", "uvw" }.ClosestMatch("abc", 0.0); // → "xyz"
  • A null sequence or a null input throws ArgumentNullException ("candidates" or "input").
  • null items in the sequence are skipped.
  • The result is the candidate as stored, with its own casing, not the input.
  • With minSimilarity 0.0 any non-empty list returns a candidate, even an unrelated one.
  • Every candidate is scored, so the cost grows with the number and length of the candidates. That is fine for a few hundred commands or options. It is not a search index.
Check for an exact match first

ClosestMatch scores every candidate even when the input is already valid. Test for an exact match first, as in the CLI example below, so you only print "did you mean" when the input is really unknown.

ToClosestEnum​

ToClosestEnum<T>() and ToClosestEnum<T>(double minSimilarity) find the enum member whose name, or whose [Description] text, is closest to the string, ignoring case. They are a typo-tolerant version of ToEnum<T> (see String parsing) for user input, query strings and configuration files.

Parse an order status that a user typed
using System.ComponentModel;

public enum OrderStatus
{
[Description("Waiting for payment")]
Pending,
Active,
Suspended,
Archived,
}

OrderStatus? typo = "pendng".ToClosestEnum<OrderStatus>(); // → OrderStatus.Pending
OrderStatus? exact = "Active".ToClosestEnum<OrderStatus>(); // → OrderStatus.Active
OrderStatus? padded = " ARCHIVED ".ToClosestEnum<OrderStatus>(); // → OrderStatus.Archived
OrderStatus? byText = "waiting for payment".ToClosestEnum<OrderStatus>(); // → OrderStatus.Pending
OrderStatus? unknown = "xyzqw".ToClosestEnum<OrderStatus>(); // → null
OrderStatus? blank = " ".ToClosestEnum<OrderStatus>(); // → null

OrderStatus? loose = "actv".ToClosestEnum<OrderStatus>(0.5); // → OrderStatus.Active
OrderStatus? strict = "actv".ToClosestEnum<OrderStatus>(0.9); // → null
Ties go to the member that Enum.GetValues lists first
DayOfWeek? day = "Sonday".ToClosestEnum<DayOfWeek>(); // → DayOfWeek.Sunday (one edit from Sunday and Monday)
  • The receiver may be null. A null, empty or whitespace-only string returns null.
  • Leading and trailing spaces are trimmed before scoring.
  • Each member is scored by its name and, when it has one, by its [Description] text (System.ComponentModel.DescriptionAttribute). Either can win.
  • T must be an enum (where T : struct, Enum). The return type is T?, so test it with .HasValue or a pattern such as is OrderStatus status.
  • An out-of-range minSimilarity throws ArgumentOutOfRangeException even when the text is null; the threshold is checked first.
  • Only member names and descriptions are compared. A numeric string such as "1" is not read as an underlying value, and a flags combination such as "Read, Write" is not split. Use ToEnum<T> for those.

See Enums for GetDescription and the other enum helpers.

GetClosestKey​

GetClosestKey(string key) and GetClosestKey(string key, double minSimilarity) return the key closest to key, ignoring case. Use them to tell a user which setting, header or option name they probably meant. They work on Dictionary<string, TValue>, IDictionary<string, TValue>, IReadOnlyDictionary<string, TValue> and any IEnumerable<KeyValuePair<string, TValue>>.

Find a mistyped setting
Dictionary<string, int> settings = new Dictionary<string, int>
{
["timeout"] = 30,
["retries"] = 3,
["port"] = 8080,
};

string? timeoutKey = settings.GetClosestKey("tmeout"); // → "timeout"
string? retriesKey = settings.GetClosestKey("retrys"); // → "retries"
string? nothing = settings.GetClosestKey("zzzzzzzz"); // → null

string? loose = settings.GetClosestKey("tmout", 0.5); // → "timeout"
string? strict = settings.GetClosestKey("tmout", 0.9); // → null
An exact key wins without scoring
Dictionary<string, int> limits = new Dictionary<string, int> { ["retries"] = 3, ["retry"] = 1 };

string? exact = limits.GetClosestKey("retry"); // → "retry"
  • A null source or a null key throws ArgumentNullException ("source" or "key").
  • An empty source returns null.
  • For an IDictionary or IReadOnlyDictionary, the exact-key check uses the dictionary's own TryGetValue, so its comparer applies. Any other sequence of pairs is read once, and its exact check is ordinal.
  • Entries with a null key are skipped.
The exact-key path returns your key, not the stored one

When the dictionary's comparer finds the key, GetClosestKey returns the key you passed in. With a StringComparer.OrdinalIgnoreCase dictionary that holds "Timeout", GetClosestKey("TIMEOUT") returns "TIMEOUT". Only the fuzzy path returns a key as stored.

Short keys match loosely

At the default threshold of 0.5, "host" matches "port": two letters out of four differ. If your keys are short, pass a higher minSimilarity, such as 0.7.

GetClosest​

GetClosest(string key) and GetClosest(string key, double minSimilarity) return the value stored under the closest key, with the same rules as GetClosestKey. Use them for a typo-tolerant lookup.

Read a value even when the key has a typo
Dictionary<string, int> settings = new Dictionary<string, int>
{
["timeout"] = 30,
["retries"] = 3,
["port"] = 8080,
};

int timeout = settings.GetClosest("tmeout"); // → 30
int missing = settings.GetClosest("zzzzzzzz"); // → 0

Dictionary<string, string> headers = new Dictionary<string, string>
{
["Content-Type"] = "text/plain",
["Content-Length"] = "12",
};

string? length = headers.GetClosest("Content-Length"); // → "12"

Dictionary<string, string> limits = new Dictionary<string, string> { ["timeout"] = "30s" };

string? loose = limits.GetClosest("tmout", 0.5); // → "30s"
string? strict = limits.GetClosest("tmout", 0.9); // → null
  • No match returns default(TValue): null for reference types, 0 for int.
  • If default is also a valid stored value, you cannot tell "no match" from "matched a zero". Call GetClosestKey first and then index the dictionary.

A "did you mean?" CLI example​

This small command-line front end accepts a command and an output format. It runs exact input directly and suggests a correction for a typo. Printing is the point here, so it writes to the console.

Program.cs
string[] commands = { "deploy", "status", "rollback", "logs", "restart" };

foreach (string input in new[] { "deplyo", "rolback", "log", "Status", "xyz" })
{
Console.WriteLine(Describe(input));
}

OutputFormat? format = "tabel".ToClosestEnum<OutputFormat>();
Console.WriteLine($"--format tabel is read as {format}");

string Describe(string input)
{
if (commands.Contains(input))
{
return $"Running '{input}'.";
}

string? suggestion = commands.ClosestMatch(input);
return suggestion is null
? $"Unknown command '{input}'. Run 'help' to list the commands."
: $"Unknown command '{input}'. Did you mean '{suggestion}'?";
}

public enum OutputFormat
{
Json,
Csv,
Table,
}
Output
Unknown command 'deplyo'. Did you mean 'deploy'?
Unknown command 'rolback'. Did you mean 'rollback'?
Unknown command 'log'. Did you mean 'logs'?
Unknown command 'Status'. Did you mean 'status'?
Unknown command 'xyz'. Run 'help' to list the commands.
--format tabel is read as Table

commands.Contains(input) is an ordinal, case-sensitive check, so "Status" is not run directly. The suggestion still finds "status" because ClosestMatch ignores case. If commands should be case-insensitive, call commands.Contains(input, StringComparer.OrdinalIgnoreCase) instead.

Natural sort​

A plain ordinal sort compares character by character, so "file10" lands before "file2" because '1' is less than '2'. A natural sort reads every run of digits as a number.

NaturalStringComparer​

NaturalStringComparer compares runs of ASCII digits by numeric value and everything else ordinally. It has no public constructor; use one of its two instances:

  • NaturalStringComparer.Ordinal is case-sensitive. Upper-case letters sort before lower-case ones, as in an ordinal sort: "B2" comes before "a10".
  • NaturalStringComparer.OrdinalIgnoreCase ignores case (invariant upper-casing): "a10" comes before "B2".

It implements both IComparer<string?> and IEqualityComparer<string?>, so it fits OrderBy, List<T>.Sort, SortedDictionary, SortedSet, HashSet, Distinct and Dictionary.

Sort file names
string[] images = { "img12.png", "img10.png", "img2.png", "img1.png", "img02.png", "img1a.png" };

string[] sorted = images.OrderBy(name => name, NaturalStringComparer.Ordinal).ToArray();
// → ["img1.png", "img1a.png", "img2.png", "img02.png", "img10.png", "img12.png"]

string[] branches = { "b10", "b2", "B3" };

string[] caseSensitive = branches.OrderBy(name => name, NaturalStringComparer.Ordinal).ToArray();
// → ["B3", "b2", "b10"]
string[] caseInsensitive = branches.OrderBy(name => name, NaturalStringComparer.OrdinalIgnoreCase).ToArray();
// → ["b2", "B3", "b10"]
Compare directly
int versions = NaturalStringComparer.Ordinal.Compare("v1.2.9", "v1.2.10"); // → negative
int nullFirst = NaturalStringComparer.Ordinal.Compare(null, "a"); // → negative
int sameText = NaturalStringComparer.OrdinalIgnoreCase.Compare("FILE10", "file10"); // → 0
int huge = NaturalStringComparer.Ordinal.Compare(
"999999999999999999999",
"1000000000000000000000"); // → negative

Rules:

  • null sorts before every string. Two nulls are equal.
  • Digit runs of any length compare without overflow: the run with more significant digits is larger, and runs of the same length compare digit by digit.
  • At the same position a digit sorts before a letter, because '0' to '9' come before letters in ordinal order: "1" sorts before "a".
  • A shorter string sorts before a longer one that starts with it: "ab" before "abc".
  • Only '0' to '9' count as digits. Other Unicode digits, such as Arabic-Indic digits, compare as ordinary characters.
Whole numbers, not decimals or signs

Each digit run is a separate whole number. "1.5" sorts before "1.25" (5 is less than 25), which is right for versions and wrong for decimal prices. A - is an ordinary character, so "-5" is not negative. Sort numeric data as numbers, not as text.

Equality and leading zeros​

Equals(x, y) is true exactly when Compare(x, y) returns 0. GetHashCode hashes the text with the leading zeros of every digit run stripped (and upper-cased for OrdinalIgnoreCase), so it agrees with Equals. That makes the comparer safe for HashSet, Dictionary and Distinct.

Runs with the same value but different leading zeros, such as "a1" and "a01", are not equal. They are only told apart when the rest of the text is equal, and the one with fewer zeros comes first. This keeps the order total and stable.

Equality semantics
bool same = NaturalStringComparer.Ordinal.Equals("file10", "file10"); // → true
bool zeros = NaturalStringComparer.Ordinal.Equals("file10", "file010"); // → false
bool caseDiffers = NaturalStringComparer.Ordinal.Equals("file10", "FILE10"); // → false
bool caseIgnored = NaturalStringComparer.OrdinalIgnoreCase.Equals("File10", "FILE10"); // → true
bool bothNull = NaturalStringComparer.Ordinal.Equals(null, null); // → true

string[] parts = { "a01b", "a1c", "a001b", "a1b" };

List<string> ordered = parts.OrderBy(part => part, NaturalStringComparer.Ordinal).ToList();
// → ["a1b", "a01b", "a001b", "a1c"]
De-duplicate chapter names ignoring case
HashSet<string> chapters = new HashSet<string>(NaturalStringComparer.OrdinalIgnoreCase);

bool added = chapters.Add("Chapter10"); // → true
bool duplicate = chapters.Add("chapter10"); // → false
bool different = chapters.Add("chapter010"); // → true
  • Compare and Equals accept null.
  • GetHashCode(null) throws ArgumentNullException (ParamName is "obj"). HashSet and Dictionary do not call it with null.
  • NaturalStringComparer is a sealed class that does not derive from StringComparer. You cannot pass it where a StringComparer parameter is required; pass it as an IComparer<string> or IEqualityComparer<string>.

StringComparer.Natural and NaturalIgnoreCase​

StringComparer.Natural and StringComparer.NaturalIgnoreCase are static extension properties that return NaturalStringComparer.Ordinal and NaturalStringComparer.OrdinalIgnoreCase. They put the natural comparers next to the built-in ones.

Use the StringComparer shorthand
string[] branches = { "b10", "b2", "B3" };

string[] natural = branches.OrderBy(name => name, StringComparer.Natural).ToArray();
// → ["B3", "b2", "b10"]
string[] naturalIgnoreCase = branches.OrderBy(name => name, StringComparer.NaturalIgnoreCase).ToArray();
// → ["b2", "B3", "b10"]

bool sameInstance = ReferenceEquals(StringComparer.Natural, NaturalStringComparer.Ordinal); // → true
Needs a C# 14 compiler

Static extension properties are a C# 14 feature. The library runs on any supported runtime, but this syntax needs a C# 14 compiler, such as the one in the .NET 10 SDK. With an older compiler, use NaturalStringComparer.Ordinal and NaturalStringComparer.OrdinalIgnoreCase directly. See Extension members.

OrderByNatural​

OrderByNatural() sorts an IEnumerable<string> in natural order, ignoring case. OrderByNatural(Func<T, string?> keySelector) sorts any items by a string key the same way. Both are shorthands for OrderBy(..., NaturalStringComparer.OrdinalIgnoreCase).

Sort invoice files
string[] invoices = { "INV-10.pdf", "inv-2.pdf", "INV-1.pdf", "inv-100.pdf", "inv-9.pdf" };

List<string> sorted = invoices.OrderByNatural().ToList();
// → ["INV-1.pdf", "inv-2.pdf", "inv-9.pdf", "INV-10.pdf", "inv-100.pdf"]
Sort documents by name
public sealed record Document(string? Name, int Id);

Document[] documents =
{
new Document("Chapter 10", 1),
new Document("chapter 2", 2),
new Document("Chapter 2", 3),
new Document(null, 4),
};

int[] ids = documents.OrderByNatural(document => document.Name).Select(document => document.Id).ToArray();
// → [4, 2, 3, 1]
  • The result is an IOrderedEnumerable<T>, so ThenBy and ThenByDescending chain after it: names.OrderByNatural().ThenBy(name => name.Length).
  • The sort is stable, like OrderBy. Above, "chapter 2" and "Chapter 2" are equal ignoring case and keep their original order.
  • null strings and null keys sort first.
  • A null source throws ArgumentNullException ("source"); a null key selector throws with "keySelector". The check runs when you call the method, not when you enumerate.
  • An empty sequence returns an empty sequence.
  • There is no case-sensitive overload. For that, call OrderBy(x => x, NaturalStringComparer.Ordinal).
Natural order as a secondary key

There is no ThenByNatural. Pass the comparer to ThenBy instead: orders.OrderBy(order => order.CustomerId).ThenBy(order => order.Number, NaturalStringComparer.OrdinalIgnoreCase).

OrderByNaturalDescending​

OrderByNaturalDescending() and OrderByNaturalDescending(Func<T, string?> keySelector) sort largest first, ignoring case. Use them for "newest version first" lists.

Newest release first
string[] releases = { "v1.2.9", "v1.2.10", "v1.10.0" };

List<string> newestFirst = releases.OrderByNaturalDescending().ToList();
// → ["v1.10.0", "v1.2.10", "v1.2.9"]

Document[] documents =
{
new Document("Chapter 2", 1),
new Document("Chapter 10", 2),
new Document(null, 3),
};

int[] ids = documents
.OrderByNaturalDescending(document => document.Name)
.Select(document => document.Id)
.ToArray();
// → [2, 1, 3]
  • null strings and null keys sort last in descending order.
  • The same argument checks as OrderByNatural apply.

See also​