Ordinal versus linguistic string comparison in .NET 10: how each works, which methods default to culture-sensitive comparison, case-insensitive comparison done right, the Turkish I problem, comparers for collections, and security implications.
Ordinal comparison compares the raw UTF-16 code units, so it's fast and identical on every machine. Culture-sensitive comparison applies a language's rules through ICU (accents, ignorable characters, ß versus ss, Turkish dotted I), so results can change with the culture or ICU version. The trap is the defaults: ==, Equals and Contains are ordinal, but StartsWith(string), IndexOf(string), Compare and CompareTo are culture-sensitive. I use Ordinal/OrdinalIgnoreCase for identifiers, keys, paths, headers and anything security-related, and CurrentCulture only for sorting or searching text a user sees. For collections I pass a StringComparer.
Ordinal comparison compares strings by their raw UTF-16 code unit values — fast, predictable and culture-independent. Culture-sensitive comparison uses linguistic rules (ICU collation) so that strings sort and match the way people expect in a particular language. The senior point: ordinal for identifiers and security decisions, culture-sensitive only for text shown to humans, and always pass StringComparison explicitly because the defaults are inconsistent.
StringComparison options| Option | Rules | Use for |
|---|---|---|
Ordinal |
Exact code unit comparison | Keys, IDs, file paths on Linux, protocol tokens, HTTP methods, JSON property names, security checks |
OrdinalIgnoreCase |
Ordinal after simple invariant upper-casing | Case-insensitive identifiers: header names, email domains, file paths on Windows, config keys, URLs schemes/hosts |
CurrentCulture |
Linguistic rules of the current culture | Sorting/searching text displayed to the current user |
CurrentCultureIgnoreCase |
Same, case-insensitive | User-facing search boxes |
InvariantCulture |
Linguistic rules of the invariant culture | Rare: culture-independent but linguistic ordering of persisted human text |
InvariantCultureIgnoreCase |
Same, case-insensitive | Rare |
Microsoft's guidance: default to Ordinal/OrdinalIgnoreCase; use CurrentCulture for user-facing display ordering; InvariantCulture almost never.
string.Compare("apple", "Banana", StringComparison.Ordinal); // > 0 ('a' 97 > 'B' 66)
string.Compare("apple", "Banana", StringComparison.CurrentCulture); // < 0 (a before b linguistically)
"strasse".Equals("straße", StringComparison.InvariantCulture); // depends on ICU rules; false in ICU (ß is not expanded to ss for equality)
"é".Equals("é", StringComparison.Ordinal); // false - different code units
"é".Equals("é", StringComparison.InvariantCulture); // true - canonically equivalent
"file".Equals("FILE", StringComparison.OrdinalIgnoreCase); // true
The Turkish-I problem: in tr-TR, "i".ToUpper() is "İ" (dotted capital I) and "I".ToLower() is "ı" (dotless i). So "FILE".ToLower() == "file" is false under Turkish culture. Any culture-sensitive case comparison of identifiers is a latent bug.
The defaults are historical and inconsistent:
| Method | Default when no comparison is passed |
|---|---|
==, string.Equals(a, b), a.Equals(b) |
Ordinal |
string.Compare(a, b), a.CompareTo(b) |
CurrentCulture |
a.StartsWith(string), a.EndsWith(string) |
CurrentCulture |
a.IndexOf(string), a.LastIndexOf(string) |
CurrentCulture |
a.IndexOf(char), a.Contains(char) |
Ordinal |
a.Contains(string), a.Replace(string, string) |
Ordinal |
a.ToUpper() / ToLower() |
CurrentCulture |
OrderBy(s => s) / List<string>.Sort() / Array.Sort(string[]) |
CurrentCulture (Comparer<string>.Default) |
Dictionary<string, T> / HashSet<string> |
Ordinal (EqualityComparer<string>.Default) |
StartsWith(char), EndsWith(char) |
Ordinal |
That's why s.IndexOf("\n") vs s.IndexOf('\n') can behave differently, and why analysers CA1307, CA1309 and CA1310 exist.
Culture-sensitive comparisons can treat different strings as equal or ignore characters entirely:
"admin".Equals("admin", CurrentCulture) can be true, while ordinal says false. A blocklist or authorisation check using culture comparison can be bypassed.So every security decision — role names, permissions, file extensions, allow/deny lists, tokens, path checks — uses Ordinal or OrdinalIgnoreCase. For secrets (tokens, HMACs), use CryptographicOperations.FixedTimeEquals on bytes to avoid timing attacks.
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var tags = new HashSet<string>(StringComparer.Ordinal);
var sortedForDisplay = names.Order(StringComparer.Create(culture, ignoreCase: true));
The comparer used for Equals and GetHashCode must match — StringComparer instances provide both.
A file-upload service blocked dangerous extensions with fileName.EndsWith(".exe"). With the default culture-sensitive EndsWith, behaviour depended on the server culture, and a crafted name containing an ignorable character slipped past the check in one environment, while case variants like .EXE slipped past everywhere. We changed to Path.GetExtension(fileName).Equals(".exe", StringComparison.OrdinalIgnoreCase) with an allow-list of extensions, normalised the name, and enabled CA1310 as an error. Separately, moving to Linux changed IndexOf("\r\n") results in a parser because ICU treats \r\n specially in linguistic search — switching to IndexOf("\r\n", StringComparison.Ordinal) fixed it.
This separates developers who have shipped globalised or security-sensitive software from those who have not.
Green flags
ToLower() comparisons.StringComparer in collections and ordinal in security checks.Red flags
ToLower for comparisons everywhere.StringComparison.Ordinal compares the char (UTF-16 code unit) sequences numerically:
"Zebra" < "apple".OrdinalIgnoreCase maps both strings using invariant simple case folding (ToUpperInvariant per char, with a fast ASCII path) and compares. It does not handle multi-character mappings like ß → SS, and it is not culture-dependent — which is exactly what you want for identifiers.
Linguistic comparison uses collation: each string is converted (conceptually) into a sort key with multiple levels of weights:
a vs b).e vs é).a vs A).ch in traditional Spanish), expansions (æ → ae), and canonical equivalence (é U+00E9 equals e + U+0301).Options like CompareOptions.IgnoreCase, IgnoreNonSpace (accents), IgnoreSymbols, IgnoreKanaType, IgnoreWidth choose which levels matter. CompareInfo.GetSortKey exposes the key; string.Compare(a, b, culture, options) uses it.
On .NET 5+ (all platforms) the implementation is ICU, replacing Windows NLS. ICU and NLS differ in edge cases — the biggest migration surprise is that embedded nulls and some control characters are ignorable in ICU, so "\r\n".IndexOf("\n") with culture rules may return -1 (ICU treats \r\n as a single grapheme), and "abc\0".Equals("abc", CurrentCulture) can be true.
Identifiers (keys, codes, paths, tokens) are not language — they are byte-like symbols with an exact meaning. Linguistic comparison introduces:
Ordinal is deterministic, fast, and matches how databases (with binary collations), file systems on Linux, and protocols compare.
SQL Server comparisons depend on column collation: the default SQL_Latin1_General_CP1_CI_AS / Latin1_General_100_CI_AS is case-insensitive and accent-sensitive. EF Core translates == to SQL =, so the database decides, not .NET. A query that is case-sensitive in unit tests (in-memory provider, ordinal) can be case-insensitive in production — another reason to test against a real database (Testcontainers) and to use EF.Functions.Collate when you need a specific behaviour.
Canonically equivalent strings (é precomposed vs e + combining acute) are different under ordinal comparison. If you need equality of user-entered identifiers (usernames), normalise first (s.Normalize(NormalizationForm.FormC)) and then compare ordinally; for security-sensitive identifiers, also consider NFKC and confusable detection (Unicode TR39) to stop look-alike attacks such as Cyrillic а vs Latin a.
StringComparer.Ordinal, OrdinalIgnoreCase, InvariantCulture, CurrentCulture, and StringComparer.Create(culture, CompareOptions) give IComparer<string> + IEqualityComparer<string>.StringComparer.FromComparison(StringComparison) maps from the enum.Span-based code: MemoryExtensions.Equals(span, other, StringComparison), span.StartsWith(..., StringComparison).string.GetHashCode(StringComparison) provides a matching hash.| Operation (20-char strings) | Approx. time |
|---|---|
Equals Ordinal |
~1–2 ns |
Equals OrdinalIgnoreCase (ASCII) |
~3–5 ns |
Compare CurrentCulture |
~30–100 ns |
IndexOf(string) CurrentCulture vs Ordinal |
often 10×+ slower |
Sorting a large list with the default culture comparer is a common hidden cost.
CultureInfo.CurrentCulture = new CultureInfo("tr-TR") and compare "FILE".ToLower() == "file"."é" and "é" with Ordinal and InvariantCulture, then normalise and compare again."a\r\nb".IndexOf("\n") with and without StringComparison.Ordinal on .NET 5+.["apple", "Banana", "cherry"] with StringComparer.Ordinal and with CurrentCulture.using System.Collections.Frozen;
using System.Globalization;
namespace Strings;
public static class Comparison
{
// Allow-list of hosts: ordinal, case-insensitive, read-only and fast
private static readonly FrozenSet<string> AllowedHosts =
FrozenSet.Create(StringComparer.OrdinalIgnoreCase, ["api.example.com.au", "portal.example.com.au"]);
public static void Main()
{
// Identifiers: ordinal
Console.WriteLine(string.Equals("Order-1", "order-1", StringComparison.OrdinalIgnoreCase)); // True
// Explicit comparison on culture-sensitive-by-default APIs
string path = "/Admin/users";
Console.WriteLine(path.StartsWith("/admin", StringComparison.OrdinalIgnoreCase)); // True
Console.WriteLine("\r\n".IndexOf("\n", StringComparison.Ordinal)); // 1
// Turkish I: ToUpper comparisons break, OrdinalIgnoreCase does not
var turkish = CultureInfo.GetCultureInfo("tr-TR");
Console.WriteLine("file".ToUpper(turkish) == "FILE"); // False
Console.WriteLine(string.Equals("file", "FILE", StringComparison.OrdinalIgnoreCase)); // True
// Dictionary keyed case-insensitively
var tenants = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) { ["Contoso"] = 1 };
Console.WriteLine(tenants.ContainsKey("CONTOSO")); // True
// User-facing sort: culture-aware
string[] names = ["zoe", "Émile", "adam", "Ben"];
var auComparer = StringComparer.Create(CultureInfo.GetCultureInfo("en-AU"), ignoreCase: true);
Console.WriteLine(string.Join(", ", names.Order(auComparer))); // adam, Ben, Émile, zoe
Console.WriteLine(string.Join(", ", names.Order(StringComparer.Ordinal))); // Ben, adam, zoe, Émile
Console.WriteLine(AllowedHosts.Contains("API.example.com.au")); // True
}
}
What happens: StartsWith, EndsWith, IndexOf(string), Compare and OrderBy are called without a comparison argument.
Why it is wrong: they default to culture-sensitive rules, so results change with server culture and ICU version.
Do instead: always pass StringComparison or a StringComparer, and enable CA1307/CA1309/CA1310.
ToLower()/ToUpper() for case-insensitive comparisonWhat happens: if (role.ToLower() == "admin").
Why it is wrong: allocates two strings and uses the current culture — the Turkish-I problem breaks it.
Do instead: string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase).
What happens: blocklists, file-extension checks, role or permission checks use culture rules. Why it is wrong: ignorable characters and case mappings allow bypasses; results vary by environment. Do instead: use ordinal comparisons, allow-lists, normalisation, and fixed-time comparison for secrets.
What happens: a Dictionary<string, T> created with the default comparer, but lookups expect case-insensitivity (or vice versa).
Why it is wrong: headers or config keys are not found; duplicates appear.
Do instead: pass StringComparer.OrdinalIgnoreCase (or the required comparer) at construction.
What happens: codes.OrderBy(c => c) used for deterministic output, file names or diffs.
Why it is wrong: culture ordering differs between machines, causing noisy diffs and non-reproducible output.
Do instead: OrderBy(c => c, StringComparer.Ordinal).
What happens: UI lists sorted with ordinal, so "Émile" sorts after "Zoe".
Why it is wrong: users expect linguistic ordering in their language.
Do instead: use StringComparer.Create(userCulture, ignoreCase: true) for display sorting.
What happens: EF Core queries assumed to be case-sensitive because C# == is ordinal.
Why it is wrong: the database collation decides; most SQL Server defaults are case-insensitive.
Do instead: know your collation, test against SQL Server, and use EF.Functions.Collate when needed.
What happens: usernames or tags compared ordinally without normalisation. Why it is wrong: visually identical strings with different code points are treated as different, allowing duplicates and impersonation. Do instead: normalise to NFC (or NFKC for identifiers) at input and consider confusable checks.
InvariantCulture thinking it means "exact"What happens: StringComparison.InvariantCulture chosen for keys because it sounds stable.
Why it is wrong: it is still linguistic (ignorable characters, canonical equivalence) and slower.
Do instead: use Ordinal for exactness.
==What happens: API keys or HMAC signatures compared with == or Equals.
Why it is wrong: early-exit comparison leaks timing information.
Do instead: compare bytes with CryptographicOperations.FixedTimeEquals.
Seniors always state the comparison and choose by purpose: machine identity versus human language.
| Purpose | Comparison | Why |
|---|---|---|
| Keys, ids, enum names, JSON | Ordinal |
exact, fast, stable |
| Headers, hosts, Windows paths, config keys | OrdinalIgnoreCase |
case-insensitive by spec, still stable |
| Security decisions | Ordinal + canonicalisation |
no linguistic equivalences |
| Display sorting and search | CurrentCulture |
matches user expectations |
| Persisted linguistic order | specific culture, versioned | ICU updates can change order |
Two things changed. First, culture data: .NET Framework on Windows used NLS, while .NET on Linux uses ICU, so any culture-sensitive call (IndexOf(string), StartsWith(string), Compare, default sorting) can give different answers; a classic one is "\r\n".IndexOf("\n") returning -1. Second, the container: slim images may not include ICU, so the app either runs in invariant globalisation mode or fails when it creates en-AU, and an unset LANG makes CurrentCulture invariant. I'd fix the code by using ordinal comparison for anything that isn't user-facing and passing cultures explicitly. Then I'd fix the image by installing ICU, or deliberately choose invariant mode, and add tests that run on Linux under several cultures.
System.Object gives every type Equals, GetHashCode, ToString, GetType, the protected MemberwiseClone and Finalize, plus static ReferenceEquals. The review problem is the Equals/GetHashCode contract: equal objects must return equal hash codes. If Equals compares Email but GetHashCode is still identity-based, two "equal" customers land in different hash buckets, so Dictionary, HashSet, Distinct and GroupBy treat them as different. I'd implement IEquatable<Customer>, override both methods using the same immutable fields, compare emails case-insensitively in both, overload ==/!=, and seal the class. For value-like data a record generates all of this.
How to tell it (STAR)
**S:** Turkish customers of our SaaS product [at company] could not reach their tenants. **T:** I had to find out why only they were affected and fix it everywhere. **A:** I reproduced it by switching the culture to tr-TR, found ToLower comparisons hit the Turkish dotted I rule, replaced them with ordinal ignore-case comparisons and comparers, and turned the analyser rules into build errors with a Turkish-culture test run. **R:** The login issue was fixed in the next release and [n] other latent comparison bugs were removed.
Last updated 30 Oct 2026.