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
.