String Comparisons in .NET

String comparison seems simple—until it isn’t. Between culture rules, case sensitivity, normalization, and performance, choosing the right comparison can be the difference between rock-solid code and subtle, global-only bugs.

Let’s break it down, figure out when to use each option, and go over the traps to avoid.

TL;DR (Quick Reference)

ScenarioUse
Protocol tokens (HTTP Bearer, MIME types, HTTP methods), JSON keys, identifiers, file extensions, GUIDsStringComparison.OrdinalIgnoreCase
Sorting or matching for end users in their UI languageStringComparison.CurrentCulture / CurrentCultureIgnoreCase
English-like, culture-stable behavior across machines (keywords in an English DSL/config)StringComparison.InvariantCultureIgnoreCase
File pathsPrefer platform APIs; if you must compare: Windows → OrdinalIgnoreCase; Linux/macOS → Ordinal
URLs/hosts, emails’ domain part (spec-like tokens)OrdinalIgnoreCase
Performance-critical, exact binary semanticsOrdinal (case-sensitive)
Dictionaries/sets keyed by stringsStringComparer.OrdinalIgnoreCase (or the culture-specific StringComparer.* as appropriate)

Golden rule: If it’s defined by a spec (not human language), prefer Ordinal (often OrdinalIgnoreCase).

The Three Axes of String Comparison

  1. Culture vs. Ordinal
    • Culture comparisons use linguistic rules (collation) and can vary by culture.
    • Ordinal compares raw Unicode code points (binary-ish), culture-agnostic.
  2. Case Sensitivity
    • IgnoreCase vs. case-sensitive.
  3. Normalization
    • Unicode characters can have multiple representations (e.g., é as one code point or e + combining accent). Some APIs handle this; sometimes you should normalize first.

The StringComparison Options (What They Mean)

  • CurrentCulture / CurrentCultureIgnoreCase Uses the thread’s CultureInfo.CurrentCulture. Best for user-facing operations where local language rules matter.
  • InvariantCulture / InvariantCultureIgnoreCase English-like rules, stable across machines. Good for English-like semantics that must be consistent regardless of the user’s locale (e.g., a DSL or config keywords written in English).
  • Ordinal / OrdinalIgnoreCase Fast, culture-agnostic, spec-correct for protocol and identifier comparisons. Default choice for most non-UI logic.

Why not rely on defaults? Because different methods have different defaults (and some depend on CurrentCulture). Always pass an explicit StringComparison.

The StringComparer Family (For Collections)


When you need a comparer for Dictionary<string, …> or HashSet<string>:

var dict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
// or
var set  = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);


Pick the comparer that matches how you intend to match keys elsewhere in your code.

API Surface: Pick the Right Overload

Use the overloads that take a StringComparison (or a StringComparer)—every time.

  • Equality: string.Equals(a, b, comparison)
  • Starts/Ends: StartsWith(value, comparison), EndsWith(value, comparison)
  • Searching: Contains(value, comparison) (modern .NET), else IndexOf(value, comparison) >= 0
  • Sorting: Array.Sort(strings, StringComparer.CurrentCultureIgnoreCase)
  • Advanced collation: CultureInfo.CurrentCulture.CompareInfo.Compare(a, b, CompareOptions.IgnoreNonSpace | CompareOptions.IgnoreCase)

Real-World Scenarios (With Recommendations)

HTTP Authorization: Bearer token

var auth = httpContext.Request.Headers.Authorization.ToString();
const string scheme = "Bearer ";

if (auth.StartsWith(scheme, StringComparison.OrdinalIgnoreCase))
{
    var token = auth.AsSpan(scheme.Length).Trim().ToString();
    // use token
}


Why: Authorization scheme is a protocol token → OrdinalIgnoreCase.

File paths (cross-platform)

bool PathEquals(string a, string b)
{
    if (OperatingSystem.IsWindows())
        return string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
    else
        return string.Equals(a, b, StringComparison.Ordinal);
}


Better: avoid manual comparison and rely on file-system APIs (FileInfo.FullName, Path.GetFullPath, etc.) when possible.

User-visible sorting/search in the UI

// Sort names as a user would expect in their locale
var sorted = names.OrderBy(n => n, StringComparer.CurrentCulture);

// Case-insensitive Contains for search box
if (title.Contains(query, StringComparison.CurrentCultureIgnoreCase)) { … }

English-like DSL or config keywords (stable across locales)

bool IsKeyword(string token) =>
    string.Equals(token, "select", StringComparison.InvariantCultureIgnoreCase);


Why: You want English-like folding (æ vs ae, ß vs ss) but consistent across machines.

JSON keys, MIME types, HTTP methods, environment variable names

if (string.Equals(method, "HEAD", StringComparison.OrdinalIgnoreCase)) { … }
if (string.Equals(contentType, "application/json", StringComparison.OrdinalIgnoreCase)) { … }


Spec tokens → OrdinalIgnoreCase.

Tricky Cases You Should Know

Turkish I problem (İ, I, i, ı)

Current culture rules can change comparison outcomes. Example:

var a = "FILE";
var b = "file";

bool current = string.Equals(a, b, StringComparison.CurrentCultureIgnoreCase); // depends on culture
bool invariant = string.Equals(a, b, StringComparison.InvariantCultureIgnoreCase); // true
bool ordinal = string.Equals(a, b, StringComparison.OrdinalIgnoreCase); // true

If your logic is protocol/identifier matching, do not depend on CurrentCulture.

German sharp s (ß) and capital ẞ

Some cultures equate ß with ss for case-insensitive matching. Ordinal comparison does not.

var a = "strasse";
var b = "straße";

string.Equals(a, b, StringComparison.CurrentCultureIgnoreCase); // true in many cultures
string.Equals(a, b, StringComparison.OrdinalIgnoreCase);        // false

Choose based on intent: human language vs. identifier.

Combining marks & normalization (é)

var composed   = "é";           // U+00E9
var decomposed = "e\u0301";     // 'e' + combining acute

// Culture comparisons may consider them equal; ordinal comparisons won't.
string.Equals(composed, decomposed, StringComparison.CurrentCulture); // often true
string.Equals(composed, decomposed, StringComparison.Ordinal);        // false

// If you need binary stability but still want equality, normalize first:
bool equalAfterNormalize = string.Equals(
    composed.Normalize(NormalizationForm.FormC),
    decomposed.Normalize(NormalizationForm.FormC),
    StringComparison.Ordinal);


Rule of thumb: if you accept free-form user text and want visually identical strings to match, normalize before comparing (or use CompareInfo.Compare with IgnoreNonSpace).

Anti-Patterns (and Better Alternatives)

❌ a.ToLower() == b.ToLower() (or ToUpper)

  • Allocations, culture-dependent, and easy to get wrong.

✅ string.Equals(a, b, StringComparison.OrdinalIgnoreCase)

  • Correct and allocation-free.

❌ Using overloads without a StringComparison

  • Defaults can be culture-based or differ across methods.

✅ Always pass StringComparison explicitly.

❌ Assuming file system case behavior

  • Windows is case-insensitive (mostly), Linux is case-sensitive.

✅ Branch by OS only if you must; otherwise use filesystem APIs.

Advanced: CompareInfo for Fine-Grained Control

When you need more than StringComparison, use CompareInfo:

var cmp = CultureInfo.CurrentCulture.CompareInfo;

// Ignore case and diacritics (combining marks)
bool equal = cmp.Compare("résumé", "resume",
    CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace) == 0;


CompareInfo exposes options like IgnoreSymbols, StringSort, etc., for nuanced search/sort scenarios.

Practical Test Snippets (Copy/Paste into a Unit Test)

[Fact]
public void Compare_ProtocolTokens_OrdinalIgnoreCase()
{
    Assert.True(string.Equals("Bearer", "bearer", StringComparison.OrdinalIgnoreCase));
}

[Fact]
public void Compare_UI_Search_CurrentCultureIgnoreCase()
{
    var title = "Résumé tips";
    Assert.True(title.Contains("resume", StringComparison.CurrentCultureIgnoreCase));
}

[Fact]
public void Compare_TurkishI_DiffersByCulture()
{
    var previous = CultureInfo.CurrentCulture;
    try
    {
        CultureInfo.CurrentCulture = new CultureInfo("tr-TR");
        Assert.True(string.Equals("FILE", "file", StringComparison.CurrentCultureIgnoreCase));

        CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
        Assert.True(string.Equals("FILE", "file", StringComparison.CurrentCultureIgnoreCase));
    }
    finally { CultureInfo.CurrentCulture = previous; }
}

[Fact]
public void Compare_Normalization_MattersForOrdinal()
{
    var composed   = "é";
    var decomposed = "e\u0301";

    Assert.False(string.Equals(composed, decomposed, StringComparison.Ordinal));
    Assert.True(string.Equals(
        composed.Normalize(NormalizationForm.FormC),
        decomposed.Normalize(NormalizationForm.FormC),
        StringComparison.Ordinal));
}

A Robust Utility You Can Reuse

public static class StringUtil
{
    public static bool EqualsOrdinalCI(string? a, string? b) =>
        string.Equals(a, b, StringComparison.OrdinalIgnoreCase);

    public static bool ContainsOrdinalCI(string s, string value) =>
        s?.Contains(value, StringComparison.OrdinalIgnoreCase) == true;

    public static bool StartsWithOrdinalCI(string s, string prefix) =>
        s?.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) == true;

    public static string NormalizeC(string s) =>
        s.Normalize(NormalizationForm.FormC);
}

Wrap-Up

  • Always be explicit about comparison semantics.
  • Spec things → Ordinal/OrdinalIgnoreCase.
  • Human things → CurrentCulture/CurrentCultureIgnoreCase.
  • English-like & stable → InvariantCultureIgnoreCase.
  • Normalize when you care about visually identical text with different Unicode forms.
  • For collections, use the corresponding StringComparer.

Leave a Comment

Your email address will not be published. Required fields are marked *