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():
- Allocation --
ToLower()allocates a new string every time. In a hot path, this creates garbage. - 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
OrdinalIgnoreCaseis the correct default for non-linguistic string comparisons: identifiers, keys, commands, URLs, file namesCurrentCultureorCurrentCultureIgnoreCaseis for user-visible text that must follow locale conventions- Never use
ToLower()for comparison -- useOrdinalIgnoreCaseinstead - Always pass
StringComparisonexplicitly toEquals,Compare,StartsWith,EndsWith - Use
StringComparerfor collections -- dictionaries, sets, sorted lists - Use
MemoryExtensionsonReadOnlySpan<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.

