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)
| Scenario | Use |
|---|---|
| Protocol tokens (HTTP Bearer, MIME types, HTTP methods), JSON keys, identifiers, file extensions, GUIDs | StringComparison.OrdinalIgnoreCase |
| Sorting or matching for end users in their UI language | StringComparison.CurrentCulture / CurrentCultureIgnoreCase |
| English-like, culture-stable behavior across machines (keywords in an English DSL/config) | StringComparison.InvariantCultureIgnoreCase |
| File paths | Prefer platform APIs; if you must compare: Windows → OrdinalIgnoreCase; Linux/macOS → Ordinal |
| URLs/hosts, emails’ domain part (spec-like tokens) | OrdinalIgnoreCase |
| Performance-critical, exact binary semantics | Ordinal (case-sensitive) |
| Dictionaries/sets keyed by strings | StringComparer.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
- Culture vs. Ordinal
- Culture comparisons use linguistic rules (collation) and can vary by culture.
- Ordinal compares raw Unicode code points (binary-ish), culture-agnostic.
- Case Sensitivity
- IgnoreCase vs. case-sensitive.
- 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.
