FuzzMatch 0.2.0

dotnet add package FuzzMatch --version 0.2.0
                    
NuGet\Install-Package FuzzMatch -Version 0.2.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="FuzzMatch" Version="0.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="FuzzMatch" Version="0.2.0" />
                    
Directory.Packages.props
<PackageReference Include="FuzzMatch" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add FuzzMatch --version 0.2.0
                    
#r "nuget: FuzzMatch, 0.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package FuzzMatch@0.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=FuzzMatch&version=0.2.0
                    
Install as a Cake Addin
#tool nuget:?package=FuzzMatch&version=0.2.0
                    
Install as a Cake Tool

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:

  1. 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,
  2. spaces are trimmed from both ends (interior runs are preserved, not collapsed),
  3. 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.Ratio on 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 with GC.GetAllocatedBytesForCurrentThread).
  • Fuzz.WRatio on the same corpus: about 630 thousand comparisons per second.
  • Distance.Levenshtein on two random 1,000-char strings: about 165 microseconds; a scoreCutoff of 5 abandons the same pair in a few columns.
  • Fuzz.PartialRatio is O(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), and Fuzz.WRatio inherits 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 Turkish I to dotless ı) are not applied.
  • Fuzz.Ratio and 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+ under WRatio.
  • 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 under WRatio, 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 via FuzzOptions.Raw, matching fuzzywuzzy and FuzzySharp. With preprocessing aligned, scores match.
  • Threshold-equal scores are always kept. A score exactly equal to scoreCutoff is 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.
  • scoreCutoff outside 0..100 throws (ArgumentOutOfRangeException); rapidfuzz accepts out-of-range cutoffs silently.
  • PartialRatio is 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.Hamming throws on length mismatch instead of padding the shorter string.
  • JaroWinkler restricts prefixWeight to 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.Default asks the .NET runtime for each character's Unicode category, and .NET's Unicode data is newer than the table rapidfuzz embeds in its compiled default_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 example Fuzz.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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • 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.

Version Downloads Last Updated
0.2.0 80 8/21/2026
0.1.1 94 8/7/2026
0.1.0 102 8/4/2026