FuzzMatch 0.2.0
dotnet add package FuzzMatch --version 0.2.0
NuGet\Install-Package FuzzMatch -Version 0.2.0
<PackageReference Include="FuzzMatch" Version="0.2.0" />
<PackageVersion Include="FuzzMatch" Version="0.2.0" />
<PackageReference Include="FuzzMatch" />
paket add FuzzMatch --version 0.2.0
#r "nuget: FuzzMatch, 0.2.0"
#:package FuzzMatch@0.2.0
#addin nuget:?package=FuzzMatch&version=0.2.0
#tool nuget:?package=FuzzMatch&version=0.2.0
FuzzMatch
Fast fuzzy string matching and best-match extraction for .NET, with rapidfuzz semantics. Zero external dependencies.
using FuzzMatch;
var match = Process.ExtractOne("new yrok", new[] { "New York", "Newark", "Boston" });
// match.Choice == "New York", match.Score == 87.5
Python teams reach for rapidfuzz (184 million downloads a month) whenever they deduplicate records, rank search suggestions, or link customer files. The .NET incumbent, FuzzySharp, collected 15 million downloads and then stopped: its last release was June 2020, and its scoring allocates heavily on every call. RapidFuzz.Net was archived in February 2024. FuzzMatch is a from-scratch implementation of the same scorer family: bit-parallel distance kernels, genuine scoreCutoff early exits, an allocation-free hot path, and scores verified against roughly ten thousand expectations generated from rapidfuzz itself.
Install
dotnet add package FuzzMatch
Quickstart: dedup and best match
using FuzzMatch;
Fuzz.Ratio("this is a test", "this is a test!"); // 100 (default preprocessing strips the "!")
Fuzz.Ratio("this is a test", "this is a test!", FuzzOptions.Raw); // 96.55172413793103
Fuzz.TokenSortRatio("fuzzy wuzzy was a bear", "wuzzy fuzzy was a bear"); // 100
Fuzz.WRatio("mariners vs angels", "los angeles angels of anaheim at seattle mariners"); // 85.5
Find likely duplicates in a customer list, objects included:
using FuzzMatch;
sealed record Customer(int Id, string Name);
Customer[] customers =
[
new(1, "Chidi Okonkwo"),
new(2, "Chidi Okonkwo Ltd"),
new(3, "Adaeze Nwosu"),
new(4, "C. Okonkwo"),
new(5, "Okonkwo Chidi"),
];
var duplicates = Process.Extract(
"Chidi Okonkwo", customers, c => c.Name, limit: 3, scoreCutoff: 85.0);
// [ (Customer 1, 100.0, index 0), (Customer 2, 95.0, index 1), (Customer 5, 95.0, index 4) ]
Deduplicate a list in one call with Process.Dedupe: it clusters items that match at or above the cutoff and keeps one survivor per cluster, the longest string, ties going to the earliest occurrence.
using FuzzMatch;
var names = new[] { "Frodo Baggins", "Frodo Baggin", "Bilbo Baggins", "Bilbo Baggins " };
var unique = Process.Dedupe(names, scoreCutoff: 90);
// [ "Frodo Baggins", "Bilbo Baggins " ] - each cluster collapses to its longest member
Process.ExtractOne returns the single best match (or null below the cutoff), Process.Extract the top limit matches sorted by score, Process.ExtractAll everything above the cutoff in original order, and Process.ExtractSorted everything above the cutoff sorted. Process.Dedupe collapses near-duplicates and returns the surviving items in first-occurrence order, with a Dedupe<T>(items, selector, ...) overload for object collections. Every extraction result carries the choice, its score, and its index in the source sequence. Ties keep the earliest choice. Any scorer below can be passed by method group: Process.ExtractOne(query, choices, scorer: Fuzz.TokenSetRatio).
Scorers
All scorers return 0 to 100. Example scores for s1 = "new york mets vs atlanta braves", s2 = "atlanta braves vs new york mets":
| Scorer | Score | What it measures |
|---|---|---|
Fuzz.Ratio |
45.2 | Normalized Indel similarity (insertions and deletions only) |
Fuzz.PartialRatio |
62.2 | Best Ratio of the shorter string against any alignment inside the longer |
Fuzz.TokenSortRatio |
100 | Ratio after sorting the words in both strings |
Fuzz.TokenSetRatio |
100 | Compares unique words; a full subset scores 100 |
Fuzz.TokenRatio |
100 | Maximum of TokenSortRatio and TokenSetRatio |
Fuzz.PartialTokenSortRatio |
100 | PartialRatio over the word-sorted strings |
Fuzz.PartialTokenSetRatio |
100 | PartialRatio over unique words; any shared word scores 100 |
Fuzz.PartialTokenRatio |
100 | Maximum of the two partial token scorers |
Fuzz.WRatio |
95 | Weighted composite of the above; the recommended default |
Fuzz.QRatio |
45.2 | Ratio, but two strings that preprocess to empty score 0 instead of 100 |
Fuzz.Ratio is Indel-based, not classic Levenshtein: substitutions count as one deletion plus one insertion. That is exactly how fuzzywuzzy's and rapidfuzz's ratio behave, so scores line up with what Python teams expect.
WRatio follows rapidfuzz's weighting rules: for strings of similar length it takes the maximum of Ratio and the token scorers scaled by 0.95; when one string is at least 1.5 times longer, the partial scorers join in, scaled by 0.9 up to a length ratio of 8 and by 0.6 beyond that.
Every scorer takes an optional scoreCutoff (0 to 100). Scores below the cutoff are returned as 0, and the cutoff is pushed down into the distance kernels, which stop as soon as the cutoff is provably unreachable. This is the same trick that makes rapidfuzz fast in extract loops: ExtractOne raises the cutoff to the best score seen so far, so later choices are abandoned earlier and earlier.
Preprocessing
By default every Fuzz scorer preprocesses both inputs with Process.Default:
- every character that is not a Unicode letter or number (categories Lu, Ll, Lt, Lm, Lo, Nd, Nl, No) becomes a space, including underscores and punctuation,
- spaces are trimmed from both ends (interior runs are preserved, not collapsed),
- the result is lowercased with the invariant culture (with U+0130 mapping to plain
i, matching rapidfuzz).
Process.Default("Hello, World_1! "); // "hello world 1"
Fuzz.Ratio("Hello!", "hello"); // 100
Fuzz.Ratio("Hello!", "hello", FuzzOptions.Raw); // below 100, compared as-is
Fuzz.Ratio("a", "A", new FuzzOptions { Processor = s => s.ToUpperInvariant() }); // 100
Note for rapidfuzz 3.x users: rapidfuzz made preprocessing opt-in; FuzzMatch applies it by default and makes it opt-out via FuzzOptions.Raw. See "Divergences from rapidfuzz" below for the canonical list of everything that intentionally differs.
Distance metrics
Distance exposes the kernels directly. Distances are edit counts; each metric also has a NormalizedSimilarity variant in 0..1.
Distance.Levenshtein("kitten", "sitting"); // 3
Distance.LevenshteinNormalizedSimilarity("kitten", "sitting"); // 0.5714285714285714
Distance.Indel("lewenstein", "levenshtein"); // 3
Distance.JaroWinklerNormalizedSimilarity("MARTHA", "MARHTA"); // 0.9611111111111111
Distance.LcsSeqSimilarity("abcd", "bcdE"); // 3 (longest common subsequence length)
Distance.Hamming("karolin", "kathrin"); // 3 (equal lengths only, otherwise throws)
| Metric | Distance | Normalization |
|---|---|---|
Levenshtein |
insertions + deletions + substitutions, uniform weights | 1 - d / max(len1, len2) |
Indel |
insertions + deletions | 1 - d / (len1 + len2) |
Jaro / JaroWinkler |
1 - similarity |
Jaro similarity, Winkler prefix boost (weight 0.1, up to 4 chars) |
Hamming |
differing positions, equal lengths required | 1 - d / length |
LcsSeq |
max(len1, len2) - LCS length |
LCS / max(len1, len2) |
Integer scoreCutoff values cap the distance: when the real distance exceeds the cutoff the functions return scoreCutoff + 1, exactly like rapidfuzz. Normalized variants return 0 below the cutoff.
Performance
Measured with this repository's test suite and perf harness (release build, .NET 8, x64). Expect about 4.5 million Fuzz.Ratio comparisons per second on a modern desktop x64 and about 2 million on a mid-range laptop; the asserted CI floors are far lower so slow runners stay green.
Fuzz.Ratioon a mixed corpus of short real-world pairs: about 4.5 million comparisons per second, with zero bytes allocated on the hot path (asserted in the suite withGC.GetAllocatedBytesForCurrentThread).Fuzz.WRatioon the same corpus: about 630 thousand comparisons per second.Distance.Levenshteinon two random 1,000-char strings: about 165 microseconds; ascoreCutoffof 5 abandons the same pair in a few columns.Fuzz.PartialRatioisO(len1 * len2 * min(len1, len2)), because it scores the shorter string against every alignment window inside the longer one. On multi-thousand-character inputs it is effectively unusable (the suite's hostile-input fuzzing skips it above 2,000 characters), andFuzz.WRatioinherits the cost whenever it invokes the partial scorers. Keep partial scoring to short-to-moderate inputs, or pre-trim long text before calling it.
The kernels are the bit-parallel algorithms that power rapidfuzz: Hyyrö's variant of Myers' algorithm for Levenshtein and the Allison-Dix bit-vector for LCS/Indel, single 64-bit word for patterns up to 64 UTF-16 code units and the multi-word block form beyond, with common prefix/suffix trimming first. Correctness of the bit-vector code is cross-checked against a naive O(n*m) dynamic-programming reference on thousands of random pairs spanning the word boundaries, and all scorers are verified against vectors generated from rapidfuzz 3.14.
Honest notes
- Preprocessing lowercases with the invariant culture, per code unit, with one deliberate exception: U+0130 (Latin capital I with dot above) maps to plain
i, matching rapidfuzz. Locale-specific casing rules (such as TurkishIto dotlessı) are not applied. Fuzz.Ratioand the distance kernels do not allocate for patterns up to 64 code units; the token scorers allocate for tokenization, and patterns longer than 64 code units use pooled buffers.- Raw-mode tokenization uses rapidfuzz's whitespace table, which is narrower than
char.IsWhiteSpace: U+0085 and U+00A0 do not split tokens, U+001C..U+001F do. With default preprocessing this is invisible, because every non-alphanumeric character becomes a regular space first. - Empty-string semantics are pinned to rapidfuzz:
Ratio("", "") == 100,PartialRatio("", "") == 100,WRatio("", "") == 0,QRatio("", "") == 0,TokenSetRatio("", "") == 0, and any one-sided empty comparison scores 0 (Ratio("a", "") == 0).
Name matching honesty
Fuzzy scores rank candidates well; they do not decide identity. Before wiring scores into a merge pipeline, know the failure modes:
TokenSetRatio's subset rule scores superset strangers 95 to 100 right alongside true word swaps:"Chidi Okonkwo"vs"Chidi Okonkwo Ltd"and vs"Okonkwo Chidi"both land at 95+ underWRatio.- Repeated-token names false-merge above any sane threshold:
"Mohammed Mohammed"matches"Mohammed Bello"at 95 because the shared token dominates. - Initials rarely win:
"C. Okonkwo"will not beat a full-name candidate for top-1 underWRatio, so initial-heavy records need their own handling.
Practical guidance: treat WRatio >= 95 as auto-merge only with a guard against the repeated-token and subset cases (for example, require comparable token counts or a second scorer's agreement), route 85 to 95 to human review, and keep everything below as search suggestions. Name screening against sanctions and watch lists is this exact problem wrapped in list management: if that is what brought you here, Sanctions.Net does the list handling and uses the same style of name matching.
Divergences from rapidfuzz
This is the canonical list; anything not listed here is intended to match rapidfuzz exactly (and is tested against it).
- Preprocessing is on by default. rapidfuzz 3.x made it opt-in (
processor=utils.default_process); FuzzMatch applies it by default and makes it opt-out viaFuzzOptions.Raw, matching fuzzywuzzy and FuzzySharp. With preprocessing aligned, scores match. - Threshold-equal scores are always kept. A score exactly equal to
scoreCutoffis returned, never dropped: the final cutoff comparison happens once, on the same 0..100 value the scorer returns. Compiled rapidfuzz sometimes drops threshold-equal matches due to float rounding in its internal 0..1 conversions. This divergence is deliberate and always in the caller's favor. scoreCutoffoutside 0..100 throws (ArgumentOutOfRangeException); rapidfuzz accepts out-of-range cutoffs silently.PartialRatiois exhaustive for needles longer than 64 code units, so it always finds the optimal window; rapidfuzz approximates via matching blocks there and can return a slightly lower score.Distance.Hammingthrows on length mismatch instead of padding the shorter string.JaroWinklerrestrictsprefixWeightto 0..0.25 (Winkler's defined range) and throws outside it; rapidfuzz accepts larger weights, which can saturate the similarity at 1.- The default processor's alphanumeric classification can differ on a few recently-assigned code points.
Process.Defaultasks the .NET runtime for each character's Unicode category, and .NET's Unicode data is newer than the table rapidfuzz embeds in its compileddefault_process. A small set of recently-assigned BMP code points (Arabic Extended-B, a few CJK ideographs, some Latin Extended-D and modifier letters, and additions to Telugu, Kannada, Tagalog, Balinese and Glagolitic) are letters in .NET but were unassigned in rapidfuzz's older table, so FuzzMatch keeps them as characters while rapidfuzz replaces them with a space. For exampleFuzz.TokenSetRatio("foo鿿bar", "bar foo")returns about 42.9 in FuzzMatch, where rapidfuzz processes"foo鿿bar"to"foo bar"and returns 100. Common BMP text is unaffected. This is purely a preprocessing-table version skew: the score kernels themselves match rapidfuzz exactly on identical processed input. A regression test freezes the known divergent set; pinning rapidfuzz's embedded table is a roadmap item. - Strings are compared as UTF-16 code units, ordinally. A character outside the Basic Multilingual Plane counts as two units and no Unicode normalization is applied. Given identical processed input the score kernels match rapidfuzz exactly on BMP text (see the processor note above for the one preprocessing caveat); astral input (emoji, rare scripts) is scored consistently but the numbers can differ from rapidfuzz's code-point results by tens of points, because every astral character weighs double.
Roadmap
- SIMD (
Vector128) inner loops for the block kernels. - A phonetics companion package (Soundex, Metaphone) for name matching.
- .NET Framework / netstandard2.0 multi-targeting if there is demand.
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.