📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is the difference between ordinal and culture-sensitive string comparison in .NET? When should you use StringComparison.Ordinal, OrdinalIgnoreCase, CurrentCulture and InvariantCulture, and which string methods default to which?

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.

Mid-Level Theory Intermediate csharp culture ordinal security string-comparison strings

🎯 Key points

  • Ordinal comparison compares UTF-16 code units numerically: fast, predictable and culture-independent
  • Culture-sensitive (linguistic) comparison applies a culture's rules via ICU: ignorable characters, accents, expansions such as ß and ss, culture-specific casing
  • Defaults differ: == / Equals / Contains / Replace are ordinal; Compare, CompareTo, StartsWith(string), EndsWith(string) and IndexOf(string) are culture-sensitive
  • Use Ordinal or OrdinalIgnoreCase for identifiers, keys, file paths, URLs, headers, configuration and security decisions
  • Use CurrentCulture to sort or search text shown to users; InvariantCulture only for culture-agnostic linguistic ordering that must be stable
  • Case-insensitive comparison: use OrdinalIgnoreCase or a StringComparer, not ToLower() == ToLower() (allocates and breaks with Turkish I)
  • Pass comparers to collections: new Dictionary<string, T>(StringComparer.OrdinalIgnoreCase)
  • Analysers CA1307, CA1309 and CA1310 flag calls without an explicit StringComparison

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. The 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.

2. How they differ in practice

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.

3. Which methods default to what

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.

4. Security implications

Culture-sensitive comparisons can treat different strings as equal or ignore characters entirely:

  • ICU ignores some zero-width and control characters in linguistic comparison, so "admin​".Equals("admin", CurrentCulture) can be true, while ordinal says false. A blocklist or authorisation check using culture comparison can be bypassed.
  • Case conversion under some cultures maps characters unexpectedly (Turkish I, German ß).

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.

5. Collections and comparers

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.

6. A production example

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.

🔍 What the interviewer is checking

This separates developers who have shipped globalised or security-sensitive software from those who have not.

Green flags

  • Explains ordinal versus linguistic comparison clearly.
  • Knows which methods default to culture-sensitive comparison.
  • Mentions the Turkish I problem and avoids ToLower() comparisons.
  • Uses StringComparer in collections and ordinal in security checks.

Red flags

  • Thinks all string methods are ordinal.
  • Normalises case with ToLower for comparisons everywhere.
  • Has never heard of StringComparison.

📚 Learn in depth

1. What ordinal comparison does

Ordinal compares the char (UTF-16 code unit) sequences numerically:

  • Equality: reference check → length check → vectorised (SIMD) comparison of memory. Extremely fast.
  • Ordering: compares code unit by code unit; the first difference decides. Upper-case ASCII letters (65–90) sort before lower-case (97–122), so "Zebra" < "apple".
  • Surrogate pairs (emoji, rare CJK) compare by code unit, which matches code point order for most practical purposes but not perfectly across all ranges.

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.

2. What culture-sensitive comparison does

Linguistic comparison uses collation: each string is converted (conceptually) into a sort key with multiple levels of weights:

  1. Primary: base letters (a vs b).
  2. Secondary: accents (e vs é).
  3. Tertiary: case and variants (a vs A).
  4. Plus rules for ignorable characters, contractions (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.

3. Why ordinal is the safe default for identifiers

Identifiers (keys, codes, paths, tokens) are not language — they are byte-like symbols with an exact meaning. Linguistic comparison introduces:

  • Non-determinism across machines: results vary by culture and ICU version.
  • False equalities: ignorable characters, canonical equivalence, accent/width options.
  • Performance cost: linguistic comparison can be 10–100× slower than ordinal because it may compute collation elements.

Ordinal is deterministic, fast, and matches how databases (with binary collations), file systems on Linux, and protocols compare.

4. Interaction with databases

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.

5. Normalisation

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.

6. Choosing comparers in APIs

  • StringComparer.Ordinal, OrdinalIgnoreCase, InvariantCulture, CurrentCulture, and StringComparer.Create(culture, CompareOptions) give IComparer<string> + IEqualityComparer<string>.
  • StringComparer.FromComparison(StringComparison) maps from the enum.
  • For Span-based code: MemoryExtensions.Equals(span, other, StringComparison), span.StartsWith(..., StringComparison).
  • string.GetHashCode(StringComparison) provides a matching hash.

7. Performance numbers (rough)

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.

Try it yourself

  1. Set CultureInfo.CurrentCulture = new CultureInfo("tr-TR") and compare "FILE".ToLower() == "file".
  2. Compare "é" and "é" with Ordinal and InvariantCulture, then normalise and compare again.
  3. Run "a\r\nb".IndexOf("\n") with and without StringComparison.Ordinal on .NET 5+.
  4. Sort ["apple", "Banana", "cherry"] with StringComparer.Ordinal and with CurrentCulture.

💻 Code

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
    }
}

⚠ Common mistakes

1. Relying on default comparison behaviour

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.

2. Using ToLower()/ToUpper() for case-insensitive comparison

What 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).

3. Culture-sensitive comparisons in security checks

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.

4. Mismatched comparers in collections

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.

5. Sorting identifiers with culture rules

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).

6. Using culture comparison for user-facing sort but invariant elsewhere inconsistently

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.

7. Expecting .NET comparison to match SQL Server

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.

8. Ignoring Unicode normalisation

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.

9. Using 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.

10. Comparing secrets with ==

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.

🏛 Senior insight: trade-offs

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
  • Turn on CA1307/CA1309/CA1310 as errors in libraries; the cost is a few explicit arguments, the benefit is predictable behaviour across Windows and Linux.
  • Remember the database: SQL Server collation decides query comparisons, which may differ from .NET in-memory comparisons in tests.
  • ICU version upgrades (OS or container base image changes) can subtly alter culture-sensitive results, another reason to keep logic ordinal.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate mentions ICU or IndexOf defaults. Why: Checks whether the candidate has seen culture-sensitive comparison break in production.
    How to answer

    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.

  2. When: The candidate passes StringComparer to a dictionary. Why: Comparers must agree with GetHashCode when strings are used in equality and collections.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Case-insensitive tenant lookup failed for Turkish users
Situation
A SaaS platform resolved tenants from a subdomain with tenantName.ToLower() == input.ToLower() and served customers in several countries, including Turkey.
Signals
Support tickets from Turkish users who could not log in to tenants whose names contained the letter I; only users with tr-TR browsers or devices were affected.
Investigation
Reproduced with CultureInfo.CurrentCulture set to tr-TR, saw ToLower() turning 'I' into dotless 'ı', and searched the codebase for ToLower/ToUpper comparisons and default StartsWith/IndexOf calls.
Resolution
Replaced the comparisons with StringComparison.OrdinalIgnoreCase, stored tenant lookups in a Dictionary with StringComparer.OrdinalIgnoreCase, and fixed a dozen similar calls.
Prevention
Enabled CA1304, CA1307, CA1309 and CA1310 as errors, and added a test run with CurrentCulture set to tr-TR in CI.

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.

↩ Usually asked after

More in Strings

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me