AlgoMaster Logo

String Comparison

Last Updated: May 22, 2026

Medium Priority
8 min read

Comparing strings in C# looks trivial until production breaks because a Turkish user typed their email in lowercase and login refused to match it. This chapter covers the operators and methods C# gives you for equality and ordering, the StringComparison enum that controls the rules, the culture pitfalls that turn one-line comparisons into bug reports, and the patterns that get used in real e-commerce code.

The == Operator Is Special on Strings

In most reference types, == checks reference identity. Two Cart objects with the same contents compare as unequal because they sit at different addresses on the heap. Strings break this rule. The == operator is overloaded on string to compare values, not references, using a byte-for-byte ordinal match.

The two variables hold characters that match one-for-one, so == returns true. ReferenceEquals peeks past the overload at the actual addresses, and those differ because string.Concat produced a fresh object on the heap.

A few details that matter:

  • The overload is case-sensitive. "Alice" == "alice" is false.
  • The overload uses ordinal rules. Two strings with the same code units are equal, full stop. Culture has no say.
  • null == null is true. "abc" == null is false. The overload handles nulls without throwing.

The intern pool is what makes ReferenceEquals return true for some literal strings ("abc" == "abc" may share storage), but you should never depend on that. Treat == as a value comparison and ReferenceEquals as the explicit identity check when you genuinely need it (which is almost never).

Equals in All Its Forms

The instance method s.Equals(other) does the same value comparison as ==. The static method string.Equals(a, b) does too, with one safety benefit: it handles null on either side without an exception.

The instance method dereferences this, so calling it on a null reference blows up. The static method is null-safe on both sides. When either operand might be null, prefer the static form.

The version that does the real work is the overload that takes a StringComparison:

This is the form to use when comparing emails for login, coupon codes case-insensitively, or HTTP header names. A later section breaks down every value of that enum.

s1.ToLower() == s2.ToLower() allocates two new strings on every call. string.Equals(s1, s2, StringComparison.OrdinalIgnoreCase) compares in place with zero allocation. On a search loop scanning thousands of products, that difference shows up in profiling.

Ordering: Compare, CompareTo, CompareOrdinal

Equality answers yes or no. Ordering answers smaller, equal, or greater. The convention across .NET is that comparison methods return a signed integer: negative if the first argument sorts before the second, zero if they're equal, positive if the first sorts after.

The exact number isn't part of the contract. All you can rely on is the sign. Treat the result like the output of a subtraction.

string.Compare has overloads that take a StringComparison, just like Equals. The default overload without one uses the current thread's culture, which is the same trap we'll cover in the culture pitfalls section.

a.CompareTo(b) is the instance form, used by Array.Sort, List<T>.Sort, and anything that implements IComparable. It also uses the current culture by default. This default is one of the most common quiet bugs in C#.

string.CompareOrdinal(a, b) is the no-culture, no-case-folding, byte-by-byte form. It's the fastest, and it's what you want any time the sort order isn't displayed to a human.

Three product codes, sorted by their underlying UTF-16 code units. No culture lookups, no surprises.

CompareOrdinal is the fastest comparison C# offers. The current-culture variants pull culture data and apply linguistic rules on every call. For an internal sort over 100,000 product codes, switching to ordinal is often a 5-10x speedup.

The StringComparison Enum

This is the heart of the chapter. StringComparison has six values, and picking the right one is the most important decision when comparing strings.

ValueRulesUse For
OrdinalByte-by-byte UTF-16 compare. Case-sensitive. No culture.Identifiers, file paths, JSON keys, coupon codes, internal lookups.
OrdinalIgnoreCaseByte-by-byte compare with ASCII case folding. No culture.Email matching, header names, case-insensitive dictionary keys.
CurrentCultureLinguistic compare using the current thread's culture. Case-sensitive.Sort orders shown to the current user.
CurrentCultureIgnoreCaseSame as above, case-insensitive per the current culture's rules.User-facing searches and filters.
InvariantCultureLinguistic compare using a fixed, culture-neutral set of rules. Case-sensitive.Data stored or transmitted that must sort the same on every machine.
InvariantCultureIgnoreCaseInvariant linguistic rules, case-insensitive.Cross-region comparisons that should ignore case but still respect linguistic equivalences.

Two questions decide which value to pick:

  1. Is the result of this comparison ever shown to a human as a sort order? If yes, use a culture-aware variant. If no, use Ordinal.
  2. Should letter case matter? If no, pick the IgnoreCase flavor.

A decision tree makes the choice mechanical:

The green leaves on the left are the safe defaults. For comparing identifiers, looking up cache keys, or matching codes, use those. The red leaves on the right are correct, but they need cultural context, which is the source of most bugs.

The same pair of strings can produce different answers depending on the rule:

That looks the same, and on .NET 8 on most platforms they are. But under some culture configurations (and historically on the full .NET Framework with NLS sorting), invariant culture treated ae and æ as linguistically equal, while ordinal always treats them as different code points. The rules are not the same and the choice must be deliberate.

The Turkish I Problem

In most cultures, the uppercase version of i is I and the lowercase of I is i. In Turkish, the alphabet has four letters where most have two:

  • Dotless lowercase ı (U+0131), whose uppercase is dotless I (U+0049).
  • Dotted lowercase i (U+0069), whose uppercase is dotted İ (U+0130).

So in Turkish culture, "i".ToUpper() produces İ, not I. And "I".ToLower() produces ı, not i. That ripples into every culture-sensitive comparison.

The same two strings, "FILE" and "file", compare as equal under English case-insensitive rules and as unequal under Turkish case-insensitive rules. A login system that uses CurrentCultureIgnoreCase will reject Turkish users intermittently depending on what culture the server thread happens to be on.

The fix is to use ordinal rules for any non-display comparison:

OrdinalIgnoreCase does case folding on ASCII without consulting any culture. i always maps to I and back, regardless of the thread culture. For identifiers, file names, configuration keys, and email addresses, this is the rule you want.

ToUpperInvariant and ToLowerInvariant

To normalize case (writing to storage, hashing, generating a slug), use ToUpperInvariant() or ToLowerInvariant() rather than ToUpper() or ToLower(). The invariant variants use a fixed, culture-neutral table so they don't shift behavior when the thread culture changes.

ToLower() produces a dotless ı because the thread is Turkish. ToLowerInvariant() produces a normal i because invariant culture doesn't have the Turkish rule. Writing handle.ToLower() into a database column on a Turkish server and then querying for handle.ToLower() on a US server will never find the row.

The rule: ToUpperInvariant and ToLowerInvariant for anything internal, ToUpper and ToLower only for text shown to a user in their own culture.

Case-Insensitive Lookups in Collections

Dictionary<string, T> and HashSet<string> hash and compare their keys. By default they use ordinal, case-sensitive comparison, which means "SAVE10" and "save10" are different keys. For a case-insensitive lookup, pass a StringComparer into the constructor:

The dictionary uses StringComparer.OrdinalIgnoreCase to hash and compare every key. The customer can type save10, SAVE10, or Save10, and they all hit the same entry. There's no per-lookup allocation, no ToLower round-trip.

The same constructor parameter works for HashSet<string>, SortedDictionary<string, T>, and SortedSet<string>. The static StringComparer properties give you all six variants: Ordinal, OrdinalIgnoreCase, CurrentCulture, CurrentCultureIgnoreCase, InvariantCulture, InvariantCultureIgnoreCase.

Switching a dictionary to StringComparer.OrdinalIgnoreCase is essentially free. Switching it to CurrentCultureIgnoreCase makes every lookup do a culture-aware comparison, which is several times slower and culture-dependent. Pick ordinal unless there's a specific reason not to.

Sorting Product Names Two Ways

A common shape in product code: one list of product names sorted differently depending on the reader. For an internal admin index where stability matters, sort ordinal. For a customer-facing listing in their language, sort by the user's culture.

Three different orders for the same five strings. Ordinal sorts by UTF-16 code unit, so uppercase letters (codes 65-90) come before lowercase (97-122), and accented characters land at the end of the BMP. Invariant culture treats É as a variant of E and case as a tiebreaker, so it slots naturally into the alphabet. A French sort agrees with invariant here; for other languages (Swedish, German with phonebook rules), the order changes.

For a sort key stored in a database for paging, use ordinal. For a sorted list shown to a user, use their culture. For one canonical order across regions, use invariant.

Common Bugs and How to Avoid Them

A few patterns show up in code reviews often enough to call out by name.

Lowercasing both sides for a comparison. This allocates two new strings and uses the current culture. It's slower and buggier than the right approach.

Using `CompareTo` for sorting without considering culture. string.CompareTo uses the current culture. On a server whose default culture varies, the same list of products can sort differently across deployments. Use string.Compare(a, b, StringComparison.Ordinal) or pass a StringComparer to Sort.

Mixing case-sensitive storage with case-insensitive matching. A common bug: emails are stored with whatever case the user typed (Alice@shop.com), and the login checks with OrdinalIgnoreCase (which works), but a separate "is this email taken?" check uses == (which doesn't). Pick one rule, write it down, apply it everywhere.

Forgetting that `Contains`, `StartsWith`, `EndsWith`, and `IndexOf` also take a `StringComparison`. All four have an overload that accepts one. Without it, Contains and IndexOf use ordinal by default (in .NET Core 2.1+ and .NET 5+), but StartsWith and EndsWith use the current culture. When in doubt, pass StringComparison.Ordinal or StringComparison.OrdinalIgnoreCase explicitly.

The StringComparison overloads of Contains, StartsWith, and IndexOf are zero-allocation. Calling product.ToUpper().Contains(query.ToUpper()) allocates two strings every search. On a product catalog scanning thousands of names per keystroke for search-as-you-type, that's the difference between fast and slow.