BrandGhost
C# String Comparison: Equals, OrdinalIgnoreCase, StringComparer, and Culture Pitfalls

C# String Comparison: Equals, OrdinalIgnoreCase, StringComparer, and Culture Pitfalls

C# String Comparison: Equals, OrdinalIgnoreCase, StringComparer, and Culture Pitfalls

C# string comparison is deceptively simple on the surface -- two strings either match or they don't. But the details matter enormously in real applications. Comparing strings the wrong way can introduce subtle bugs that only surface in specific locales, cause security vulnerabilities, produce incorrect sort orders, or silently corrupt data.

This guide covers all the comparison APIs in .NET, explains when to use each, and walks through the culture-related pitfalls that trip up even experienced developers.


The Default: == Operator

The == operator on strings performs an ordinal comparison -- it compares character by character based on Unicode code unit values, with exact case sensitivity:

var a = "Hello";
var b = "Hello";
var c = "hello";

Console.WriteLine(a == b); // True -- same content
Console.WriteLine(a == c); // False -- different case

== uses the string equality operator, which performs an ordinal, case-sensitive comparison. This is the correct default for most internal string comparisons.


string.Equals() and StringComparison

The string.Equals() overload with a StringComparison parameter is the foundation of correct string comparison in .NET:

var a = "Hello";
var b = "HELLO";

// Case-insensitive ordinal comparison (recommended for most cases)
bool equal = string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
Console.WriteLine(equal); // True

// Equivalent instance method form
bool alsoEqual = a.Equals(b, StringComparison.OrdinalIgnoreCase);

The StringComparison Enum

Value Case Culture Use For
Ordinal Sensitive None File names, keys, binary data
OrdinalIgnoreCase Insensitive None Command names, URLs, identifiers
InvariantCulture Sensitive Invariant Stored/serialized text
InvariantCultureIgnoreCase Insensitive Invariant Stored/serialized text, ignore case
CurrentCulture Sensitive User locale User-facing display text
CurrentCultureIgnoreCase Insensitive User locale User-facing search/filter

The critical insight: you should always pass a StringComparison explicitly. Omitting it means accepting the default, which is StringComparison.Ordinal for string.Equals() and string.Compare(), but CurrentCulture for string.CompareTo() -- an inconsistency that is easy to miss.


The ToLower() Antipattern

The most common string comparison mistake in C# is converting to lowercase before comparing:

// ❌ WRONG -- allocates two new strings, and fails in some cultures
if (input.ToLower() == "admin")
{
    // ...
}

// ❌ Also wrong -- ToLowerInvariant is better than ToLower but still allocates
if (input.ToLowerInvariant() == "admin")
{
    // ...
}

// ✅ CORRECT -- no allocation, culture-safe
if (string.Equals(input, "admin", StringComparison.OrdinalIgnoreCase))
{
    // ...
}

There are two problems with ToLower():

  1. Allocation -- ToLower() allocates a new string every time. In a hot path, this creates garbage.
  2. The Turkish i problem -- see below.

The Turkish i Problem

Turkish (and Azerbaijani) have four forms of the letter i: lowercase i (with dot), uppercase İ (with dot), lowercase ı (without dot), uppercase I (without dot). In Turkish culture, "i".ToUpper() is "İ", not "I".

// ❌ This FAILS on a machine with Turkish locale
var culture = new System.Globalization.CultureInfo("tr-TR");
bool bad = "file".ToUpper(culture) == "FILE"; // False! "FILE" vs "FİLE"

// ✅ Ordinal comparison is immune to locale
bool good = string.Equals("file", "FILE", StringComparison.OrdinalIgnoreCase); // True

This is not a theoretical edge case. .NET applications that run in Turkish-locale environments will have bugs if they use ToLower() / ToUpper() for comparisons. Use OrdinalIgnoreCase for all non-linguistic comparisons.


string.Compare(): Ordering and Sorting

string.Compare() returns an integer indicating the relative order of two strings:

int result = string.Compare("apple", "banana", StringComparison.OrdinalIgnoreCase);
// result < 0: "apple" comes before "banana"
// result == 0: equal
// result > 0: "apple" comes after

This is what IComparer<string> and IComparable<string> use internally. When sorting collections, you need an appropriate comparer:

var names = new List<string> { "Zebra", "apple", "Mango", "banana" };

// ❌ Default sort -- uses current culture comparison rules (not ordinal)
names.Sort(); // apple, banana, Mango, Zebra (culture-dependent ordering)

// ✅ Culture-aware natural sort for display
names.Sort(StringComparer.CurrentCultureIgnoreCase);
// apple, banana, Mango, Zebra (natural alphabetical)

StringComparer: For Collections and Dictionaries

StringComparer provides IComparer<string> and IEqualityComparer<string> implementations for use with collections:

// Case-insensitive dictionary
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
headers["Content-Type"] = "application/json";

// All of these find the same key
Console.WriteLine(headers["content-type"]);     // application/json
Console.WriteLine(headers["CONTENT-TYPE"]);    // application/json
Console.WriteLine(headers["Content-Type"]);    // application/json
// Case-insensitive sorted set
var commands = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
commands.Add("start");
commands.Add("Stop");
commands.Add("STATUS");

// Fast lookup, case-insensitive
bool hasStart = commands.Contains("START"); // True
// Available StringComparer instances
StringComparer.Ordinal                   // Case-sensitive, no culture
StringComparer.OrdinalIgnoreCase         // Case-insensitive, no culture
StringComparer.InvariantCulture          // Case-sensitive, invariant culture
StringComparer.InvariantCultureIgnoreCase // Case-insensitive, invariant culture
StringComparer.CurrentCulture            // Case-sensitive, user's locale
StringComparer.CurrentCultureIgnoreCase  // Case-insensitive, user's locale

Span-Based Comparison (.NET 5+)

For high-performance comparisons without heap allocation, use the MemoryExtensions extension methods on ReadOnlySpan<char>:

var line = "Content-Type: application/json";

// No allocation -- compare a slice directly
bool isContentType = line.AsSpan(0, 12).Equals(
    "Content-Type".AsSpan(),
    StringComparison.OrdinalIgnoreCase);

Console.WriteLine(isContentType); // True

This is the same as string.Equals() semantically but works on ReadOnlySpan<char> so it can compare slices of strings without creating intermediate substring objects.


Comparing with StartsWith and EndsWith

StartsWith and EndsWith also accept StringComparison:

var url = "https://www.devleader.ca/blog";

// ✅ Always pass StringComparison
bool isHttps = url.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
bool isBlog = url.EndsWith("/blog", StringComparison.OrdinalIgnoreCase);

// For Span<char> (no allocation)
bool isHttpsSpan = url.AsSpan().StartsWith(
    "https://".AsSpan(),
    StringComparison.OrdinalIgnoreCase);

Omitting StringComparison on StartsWith and EndsWith uses CurrentCulture -- almost certainly not what you want for URL or path comparisons.


String Equality in Switch Statements and Pattern Matching

Pattern matching in C# 7+ works with string literals but is case-sensitive and ordinal by default. The recommended case-insensitive switch uses string.Equals with OrdinalIgnoreCase in a when clause:

var command = "START";

// ✅ Case-insensitive switch -- explicit OrdinalIgnoreCase comparison
var result = command switch
{
    _ when string.Equals(command, "start", StringComparison.OrdinalIgnoreCase) => "Starting...",
    _ when string.Equals(command, "stop", StringComparison.OrdinalIgnoreCase) => "Stopping...",
    _ => "Unknown"
};

You may also see the ToUpperInvariant() normalisation pattern -- convert once, then switch on uppercase literals. This works but is not preferred because it hides the intent and creates an intermediate string:

// ⚠️ Works, but prefer the explicit OrdinalIgnoreCase pattern above
var result = command.ToUpperInvariant() switch
{
    "START" => "Starting...",
    "STOP" => "Stopping...",
    "STATUS" => "Running",
    _ => "Unknown command"
};

Practical Example: Case-Insensitive Configuration Parser

using System.Collections.Frozen;

namespace StringDemo;

public sealed class ConfigParser
{
    // FrozenDictionary (.NET 8+) for read-only, high-performance lookup
    private readonly FrozenDictionary<string, string> _settings;

    public ConfigParser(IEnumerable<KeyValuePair<string, string>> rawSettings)
    {
        // OrdinalIgnoreCase -- configuration keys are not linguistic
        _settings = rawSettings.ToFrozenDictionary(
            kvp => kvp.Key,
            kvp => kvp.Value,
            StringComparer.OrdinalIgnoreCase);
    }

    public string? Get(string key) =>
        _settings.TryGetValue(key, out var value) ? value : null;

    public bool TryGet(string key, out string value) =>
        _settings.TryGetValue(key, out value!);
}

FrozenDictionary<string, string> (introduced in .NET 8) builds an optimized read-only hash map. Combined with OrdinalIgnoreCase, it gives you fast, correct, case-insensitive key lookup.


Practical Example: HTTP Header Comparison

HTTP headers are case-insensitive per the HTTP specification. Here is a correct implementation:

namespace StringDemo;

public static class HttpHeaderHelper
{
    // Use OrdinalIgnoreCase -- HTTP headers are ASCII, not linguistic
    private static readonly StringComparer _headerComparer =
        StringComparer.OrdinalIgnoreCase;

    public static bool HasHeader(
        IEnumerable<KeyValuePair<string, string>> headers,
        string name)
    {
        foreach (var header in headers)
        {
            if (_headerComparer.Equals(header.Key, name))
            {
                return true;
            }
        }
        return false;
    }

    public static string? GetHeader(
        IEnumerable<KeyValuePair<string, string>> headers,
        string name)
    {
        foreach (var header in headers)
        {
            if (_headerComparer.Equals(header.Key, name))
            {
                return header.Value;
            }
        }
        return null;
    }
}

Comparison in Broader C# Contexts

String comparison comes up throughout .NET development. When working with C# enumerations, you sometimes parse enum values from string input and need case-insensitive matching. The C# Enum to String guide covers this in detail.

When building plugin architectures in C#, you often match plugin names or identifiers by string. Using OrdinalIgnoreCase for these lookups ensures your plugin system works correctly regardless of the machine's locale settings.

The decorator design pattern in C# is often used to wrap services with caching. When building a string-keyed cache, the StringComparer you choose for the backing dictionary determines whether cache hits work correctly across different case conventions.

Feature-based code organization (see feature slicing in C#) often involves routing commands or events by string name. Case-insensitive comparison with OrdinalIgnoreCase makes these systems more robust.


Summary: Rules of Thumb

  1. OrdinalIgnoreCase is the correct default for non-linguistic string comparisons: identifiers, keys, commands, URLs, file names
  2. CurrentCulture or CurrentCultureIgnoreCase is for user-visible text that must follow locale conventions
  3. Never use ToLower() for comparison -- use OrdinalIgnoreCase instead
  4. Always pass StringComparison explicitly to Equals, Compare, StartsWith, EndsWith
  5. Use StringComparer for collections -- dictionaries, sets, sorted lists
  6. Use MemoryExtensions on ReadOnlySpan<char> for allocation-free comparison of substrings

.NET 10: CompareOptions.NumericOrdering

One of the most requested string comparison features arrived in .NET 10: natural numeric ordering. By default, string comparison is lexicographic, which means "file10" sorts before "file2" because '1' < '2' in character code. This is rarely what users expect.

.NET 10 adds CompareOptions.NumericOrdering to StringComparer and CompareInfo, enabling numeric segments inside strings to sort by numeric value:

using System.Globalization;

var files = new[] { "file10.txt", "file2.txt", "file1.txt", "file20.txt" };

// Default lexicographic order: file1.txt, file10.txt, file2.txt, file20.txt
var lexicographic = files.Order().ToArray();

// .NET 10 NumericOrdering: file1.txt, file2.txt, file10.txt, file20.txt
var comparer = StringComparer.Create(
    CultureInfo.CurrentCulture,
    CompareOptions.NumericOrdering);
var natural = files.Order(comparer).ToArray();

CompareOptions.NumericOrdering works across all CompareInfo APIs and integrates with LINQ ordering. Use it when sorting user-visible lists of file names, version numbers, or identifiers where numeric segments should sort by value rather than character code.

FAQ

What is the difference between Ordinal and OrdinalIgnoreCase?

Both use byte-by-byte comparison with no cultural rules. Ordinal is case-sensitive; OrdinalIgnoreCase is case-insensitive. OrdinalIgnoreCase uses a fast invariant uppercasing rule that is immune to locale-specific transformations like the Turkish i.

Why should I avoid using == for case-insensitive comparison?

The == operator is case-sensitive and ordinal. To compare case-insensitively, you must use string.Equals(a, b, StringComparison.OrdinalIgnoreCase). The ToLower() trick works in simple cases but allocates extra strings and fails on Turkish-locale machines.

When should I use CurrentCulture for string comparison?

Use CurrentCulture for strings that are displayed to the user and must follow the user's language conventions -- for example, sorting a list of user names for display, or matching user input against a localized list of keywords. For internal identifiers, keys, and system strings, always use ordinal comparison.

What is the Turkish i problem?

In Turkish culture, the uppercase of lowercase i is İ (with dot), not I. This means "i".ToUpper(new CultureInfo("tr-TR")) returns "İ". Code that uses ToLower()/ToUpper() for comparison will produce wrong results on Turkish-locale machines. OrdinalIgnoreCase avoids this by using invariant casing rules.

How do I make a case-insensitive Dictionary in C#?

Pass StringComparer.OrdinalIgnoreCase to the Dictionary constructor: new Dictionary<string, T>(StringComparer.OrdinalIgnoreCase). For high-performance read-only lookups in .NET 8+, use FrozenDictionary with the same comparer.

What is StringComparer and when do I need it?

StringComparer provides implementations of IComparer<string> and IEqualityComparer<string>. Use it when you need to pass a string comparison strategy to a collection (Dictionary, HashSet, SortedSet, List.Sort, LINQ .OrderBy, etc.).

Is string.Compare the same as string.Equals?

No. string.Compare returns an integer (negative, zero, or positive) indicating order, and uses CurrentCulture by default. string.Equals returns a bool and uses ordinal comparison by default. Both accept explicit StringComparison values. Use Compare for sorting; use Equals for equality checks.

What Is String Interpolation In C# - What You Need To Know

What is string interpolation in C#? Learn about its definition, syntax, and benefits for improving code readability and efficiency. Check out this guide!

How to Compare Strings in CSharp: Tips and Tricks You Need to Know

Wondering how to compare strings in CSharp? We'll compare using string.Equals(), string.Compare(), and == operator to weigh the pros and the cons.

C# Strings: Complete Guide to String Manipulation in .NET

Master C# string manipulation with this complete guide covering .NET 6-9 APIs, Span, raw literals, StringBuilder, and performance best practices.

An error has occurred. This application may no longer respond until reloaded. Reload