Last Updated: May 22, 2026
Every collection that sorts, orders, hashes, or compares for equality leans on one of four contracts: IComparable<T>, IComparer<T>, IEquatable<T>, or IEqualityComparer<T>. The first two define ordering. The second two define equality. This lesson covers all four, when to use each, how to build custom ones, and the bugs that show up when the contracts are violated.
C# collections need to answer two questions about the objects they hold. The first is "given two of these, which comes first?" That's ordering, and it powers Sort, OrderBy, SortedDictionary, SortedSet, and PriorityQueue. The second is "are these two the same?" That's equality, and it powers Dictionary keys, HashSet, Distinct, GroupBy, and Contains.
For each question, the BCL gives you two ways to provide an answer: bake it into the type itself, or supply it from the outside.
| Contract | Question | Where it lives | Common consumers |
|---|---|---|---|
IComparable<T> | "How do I compare to another T?" | On the type itself | Array.Sort, List<T>.Sort, SortedSet<T>, SortedDictionary<TKey, TValue> |
IComparer<T> | "Given two T values, which is smaller?" | Separate comparer object | All sorting APIs that take a comparer parameter |
IEquatable<T> | "Am I equal to another T?" | On the type itself | List<T>.Contains, Dictionary key matching, HashSet<T> |
IEqualityComparer<T> | "Are these two T values equal, and what's the hash code?" | Separate comparer object | Dictionary, HashSet, Distinct, GroupBy, ToDictionary |
The "on the type itself" versions describe the natural, default behavior. A decimal compares to another decimal the way numbers compare. A string compares to another string ordinally by default. When you write new Product { ... }, the natural ordering question is what IComparable<T> answers, and the natural equality question is what IEquatable<T> answers.
The "separate comparer object" versions describe alternative behaviors. You might want products sorted by name today and by price tomorrow. You might want strings compared case-insensitively for one dictionary and case-sensitively for another. That's what IComparer<T> and IEqualityComparer<T> are for: they let the same type be ordered or hashed in multiple ways depending on context.
The arrows on the right matter. IComparable<T> and IEquatable<T> are answers the type itself provides; you can only have one of each. IComparer<T> and IEqualityComparer<T> are answers an outside object provides; you can have as many as you need, and you pass the one you want when you call into the collection.
The rest of the lesson works through both sides. We'll start with ordering (because the contract is simpler), then move to equality (where the hash code rule adds a wrinkle), and finish with the bugs that show up when these rules are bent.
Both IComparable<T>.CompareTo and IComparer<T>.Compare return an int. The sign of that int answers "which is smaller?" Not the magnitude, just the sign.
That's it. -1, -7, and int.MinValue all mean "smaller." +1, +42, and int.MaxValue all mean "larger." Sorting algorithms only ever look at the sign. The shape of the contract looks like this:
A naive way to implement a comparer is to write return x - y; when x and y are numbers. That works for small positive numbers and fails for large ones, because the subtraction can overflow. int.MinValue - 1 is not negative, it's int.MaxValue. Always use CompareTo on the field, or a built-in helper like Comparer<T>.Default.Compare, so the BCL handles overflow correctly.
The subtraction returns int.MaxValue because the math wraps. Any sort using x - y on this pair would put int.MinValue after 1, which is wrong. a.CompareTo(b) returns -1, the correct sign, because CompareTo doesn't subtract; it branches.
On top of the sign rule, the contract has three consistency requirements that every comparer must respect:
Compare(x, x) must return 0. A value is equal to itself for ordering.Compare(x, y) < 0, then Compare(y, x) > 0. Swapping the arguments must swap the sign.Compare(x, y) < 0 and Compare(y, z) < 0, then Compare(x, z) < 0. Less-than chains correctly.Sorting algorithms assume all three. When you violate transitivity, List<T>.Sort does not throw; it produces a wrong order. The result looks plausible until you look closely, which is why broken comparers tend to sit in codebases for years before someone notices.
List<T>.Sort uses an introsort variant that calls the comparer O(n log n) times for n elements. If Compare does anything expensive (string parsing, regex, database lookup), the cost stacks up fast. Cache the comparison keys before sorting when the comparison work is heavy.
When ordering is intrinsic to the type, implement IComparable<T> on the type itself. The single method is int CompareTo(T other).
products.Sort() with no argument calls Comparer<Product>.Default, which delegates to Product.CompareTo because the type implements IComparable<Product>. The line Price.CompareTo(other.Price) does the actual work: it returns a negative number when the current product is cheaper, zero when prices match, and a positive number when it's more expensive. The sort picks ascending order from those signs.
The null check at the top isn't decoration. The BCL convention is that null sorts before everything else, which mirrors how Comparer<string>.Default handles null strings. CompareTo(null) should return a positive integer, meaning "I (the non-null instance) am greater than null."
IComparable<T> is for the one natural order a type has. A decimal has one natural order: numerical. A DateTime has one: chronological. A Product's natural order is a design decision, and you should pick the one that's correct everywhere it'll be used by default. If two answers feel equally natural ("by name" and "by price" are both reasonable for Product), that's a sign the type doesn't have a natural order, and you should leave IComparable<T> off and force callers to pass an explicit comparer.
IComparer<T> answers the same question as IComparable<T>, but from the outside. The method is int Compare(T x, T y). A comparer object can sort any type, including types you don't own. You implement it when:
The simplest comparer is a Comparer<T>.Create call that wraps a lambda. No new class, no boilerplate.
The lambda receives two Product instances and returns the sign of their name comparison. string.Compare is the BCL helper that takes the explicit StringComparison flag, which matters for any cross-platform or cross-culture code. The result is sorted alphabetically by name, regardless of what Product.CompareTo might do, because the explicit comparer wins over the type's natural order.
When the comparison logic is bigger than a lambda, write a class.
The class implements IComparer<Product> and bundles three rules: nulls first, in-stock before out-of-stock, then price ascending within each group. Sort sees Notebook and Mouse (both in stock) come before Keyboard and Headphones (both out of stock), and within each group the cheaper one comes first.
Two things worth noticing in the implementation. First, the null branches go before the field access. If you call x.StockCount after the null check passes, the compiler can prove x is not null and stops warning. Second, the actual comparison logic uses decimal.CompareTo rather than subtraction, which avoids the overflow we discussed earlier.
Multi-field ordering ("by category, then by price, then by name") is so common that LINQ has dedicated operators for it: OrderBy and ThenBy. The pattern is the same in raw IComparer<T> code, just spelled differently.
LINQ's OrderBy returns an IOrderedEnumerable<T> which has the ThenBy method. Each call adds another tie-breaker: when categories are equal, look at price; when both are equal, look at name. The output shows Earbuds and Speakers both at $49.99 in the Audio category, ordered by name within the price tie.
For non-LINQ code that needs the same structure, write a comparer that does the chaining itself.
The pattern is: do the first comparison, return early when it's nonzero, fall through to the next field, and so on. The last field doesn't need an if because its result is the answer when everything else tied. That int byCategory = ...; if (byCategory != 0) return byCategory; template is what ThenBy does internally.
The null handling at the top of Compare is a little dense. Reading it out: if both are null, (0) - (0) is 0. If x is null and y isn't, (0) - (1) is -1, meaning "null comes first." If x isn't and y is, (1) - (0) is 1. That's the same null-sorts-first behavior the BCL uses everywhere.
To reverse an order, you don't usually write a new comparer. You wrap the existing one.
The trick is to swap the arguments inside the lambda. b.Price.CompareTo(a.Price) returns the opposite sign of a.Price.CompareTo(b.Price), which flips the order from ascending to descending. The same pattern works in any custom comparer: implement ascending normally, build a descending wrapper that delegates and negates.
You might be tempted to write return -inner.Compare(x, y); to reverse a comparer. That looks clean and works most of the time, but it has one trap: int.MinValue negated is still int.MinValue because of two's-complement overflow. A well-behaved comparer never returns int.MinValue, but if you're wrapping a third-party comparer you didn't write, the safer form is inner.Compare(y, x) (swap the arguments) which can't overflow.
The class form is useful for a named, reusable wrapper. _inner.Compare(y, x) is the safe reversal idiom. For a method that receives an IComparer<T> and needs to provide both directions, this is the helper to use.
Once you have a comparer, you can hand it to most BCL collections that care about ordering. Each takes the comparer in slightly different places.
A few points across the four collections. List<T>.Sort(IComparer<T>) is one-shot: it sorts once with that comparer and doesn't remember. SortedSet<T> and SortedDictionary<TKey, TValue> take the comparer at construction time and use it for every later insert; swapping it out means building a new collection. PriorityQueue<TElement, TPriority> uses the comparer for priorities (not elements), and the priorities are usually a separate value the caller picks per enqueue.
The PriorityQueue line uses byPrice: null, which is the default comparer for decimal, where smaller priority comes out first (a min-heap by default). Pass a custom comparer to flip to max-heap or to use a non-numeric priority type.
LINQ's OrderBy(keySelector, comparer) takes a comparer for the key, not for the element. When you write products.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase), the comparer compares the extracted name keys, not the products. That distinction matters when you compose OrderBy with ThenBy chains, since each level gets its own key selector and optional comparer.
SortedSet<T> and SortedDictionary<TKey, TValue> are red-black trees. Every insert, lookup, and remove calls the comparer O(log n) times. A comparer that touches a string or a database carries a hidden constant inside that O(log n). Cache the key once on the element rather than recomputing it each call.
Equality is the second question, and it has a wrinkle ordering doesn't have: hash codes. Before getting to the wrinkle, the simpler half is IEquatable<T>, the type's own equality answer.
IEquatable<T> has one method: bool Equals(T other). When a type implements it, BCL APIs that look for equality (like List<T>.Contains or Dictionary key matching, for non-record types) call the typed Equals and skip the slower object.Equals(object) path that involves a cast.
Three methods, three jobs. Equals(Sku? other) is the typed equality, and it does the actual work: case-insensitive comparison of the code strings. Equals(object? obj) is the override of the base method on object; it delegates to the typed version after a type check. GetHashCode() hashes the code with the same case-insensitive rule, so "PRD-001" and "prd-001" have the same hash.
The HashSet<Sku> uses the type's own Equals and GetHashCode because no explicit comparer was passed. seen.Contains(b) returns True because b hashes to the same bucket as a and then b.Equals(a) is true. seen.Contains(c) returns False because the codes differ.
All four pieces (the typed Equals, the object Equals, the GetHashCode, and any operator overloads) must agree. Overriding one is a commitment to override the others. Records make this easier because the compiler synthesizes all four based on the record's positional fields.
For a record, Sku("PRD-001") == Sku("PRD-001") is true because the compiler-generated Equals compares the Code property. The catch is that records use the default string comparer (case-sensitive ordinal), so Sku("PRD-001") == Sku("prd-001") is false. If you want case-insensitive SKU equality on a record, you still have to override Equals and GetHashCode, or use IEqualityComparer<T> from the outside.
The two methods are bound by a single rule: if two objects are equal, they must have the same hash code. The reverse is not required: two objects with the same hash code may be unequal. That's a collision, and HashSet<T> and Dictionary<TKey, TValue> handle collisions by linear scan within a bucket.
The asymmetry matters because of how hash tables work. To find a key, the table:
Equals to find the actual match.If two equal objects produce different hash codes, step 2 sends them to different buckets, and step 3 never finds the match. The dictionary silently loses keys. Calls to Contains return false for items that are clearly in the set. Items show up twice. The bug is not loud; the data is just wrong.
The reverse direction (two unequal objects with the same hash code) is fine. Step 2 sends them to the same bucket, step 3 calls Equals on each candidate, and the unequal ones get rejected. The cost is a slightly longer scan in that bucket, which is what collisions look like in practice. A well-distributed hash function keeps the average bucket short; a bad one piles everything into one bucket and turns O(1) lookups into O(n).
The rule has a corollary that affects real code: don't include mutable fields in `GetHashCode` if the object lives in a hash-based collection. If an object's hash changes after it's been put in a HashSet<T>, it lands in the wrong bucket on the next lookup. The bug shows up as "the item is in the set, but Contains says it isn't."
The safe rule of thumb is to derive equality and hash codes from immutable fields only. For a type with an ID that never changes after construction, hash on the ID. For a type with mutable fields that participate in equality, either make the type immutable (records help here) or keep instances out of hash-based collections.
A bad GetHashCode (returning a constant, or hashing only one field of many) turns every dictionary lookup into a linear scan of every key. Profilers often show this as "Equals is slow," but the real problem is the hash, not the comparison.
IEqualityComparer<T> is to IEquatable<T> what IComparer<T> is to IComparable<T>. It lets an outside object define equality and hashing for a type, without modifying the type. Both methods on the interface must agree: Equals(x, y) returns true exactly when GetHashCode(x) equals GetHashCode(y) for equal objects.
The SkuComparer declares two products equal if their SKUs match case-insensitively. The hash uses the same rule, so "prd-001" and "PRD-001" hash to the same value. catalog.Contains(lookup) returns true because the SKU matches, even though the name and price differ. The attempted second insert with "prd-002" fails because "PRD-002" is already in the set under the case-insensitive rule, and HashSet<T>.Add returns false on duplicates.
The reasoning is the same as before, just in a separate object. The first three lines of Equals are the standard preamble: identity first (cheap), null check second (correctness), then the actual comparison. Skipping the identity check is fine but slightly slower for repeated lookups of the same instance. Skipping the null check is a bug; Equals(null, null) should return true and Equals(notNull, null) should return false, neither of which works without explicit handling.
The same comparer plugs into LINQ.
Distinct and GroupBy both accept an IEqualityComparer<T> as their second argument. Pass the SKU comparer and the operations dedupe and group by SKU (case-insensitively) instead of by reference. Without the comparer, two records with different memory locations but the same field values are considered equal because records have value-based equality by default; the comparer overrides that and uses only the SKU.
ToDictionary works the same way, taking a comparer for keys:
The dictionary stores the keys exactly as inserted ("PRD-001", etc.) but compares them with StringComparer.OrdinalIgnoreCase, so lookup["prd-001"] and lookup["PRD-001"] both find the same entry. StringComparer is the BCL-provided implementation of IEqualityComparer<string> (and IComparer<string>) for common string comparison flavors.
StringComparer is a small family of singleton comparers in System that implement both IComparer<string> and IEqualityComparer<string>. Use them whenever you need a non-default string comparison, which is most of the time.
| Comparer | What it does | When to use |
|---|---|---|
StringComparer.Ordinal | Byte-for-byte comparison, no culture rules | Identifiers, SKUs, file paths, anything that's not user-visible text |
StringComparer.OrdinalIgnoreCase | Byte-for-byte, ASCII case folded | Case-insensitive identifiers (email addresses, usernames, headers) |
StringComparer.CurrentCulture | Compares using the current thread's culture | Sorting user-visible names for display |
StringComparer.CurrentCultureIgnoreCase | Same, but case-insensitive | User-facing search and matching |
StringComparer.InvariantCulture | Culture-aware, but pinned to invariant culture | Sortable text that must be stable across machines |
StringComparer.InvariantCultureIgnoreCase | Same, case-insensitive | Stable case-insensitive comparison, no culture drift |
Culture-aware comparers know that ß and SS are linguistically equivalent in German-style collation. Ordinal comparers don't, because they treat strings as sequences of code units. For SKUs, headers, file paths, and other non-prose identifiers, Ordinal and OrdinalIgnoreCase are the right defaults: they're faster, they don't change behavior when the user's locale changes, and they don't surprise anyone with linguistic equivalences.
The general rule the BCL pushes is: ordinal for code, current culture for display, invariant culture for stored data that needs stable comparison. Mixing them up is a frequent source of bugs in apps that work in development on one locale and behave differently in production on another.
HTTP headers are case-insensitive by spec, so building the dictionary with StringComparer.OrdinalIgnoreCase matches the protocol's behavior. The keys are stored as inserted ("Content-Type"), but lookups normalize for case during comparison.
Comparers and equality methods are not hard to write, but they have several failure modes that don't surface until much later. These are the ones that show up most often in real code.
This is the canonical mistake. The compiler warns about it (warning CS0659), but warnings get ignored.
a and b are equal by the override, but their default hash codes are based on object identity (different memory locations, different hashes). The HashSet<Sku> puts a in one bucket and looks for b in a different bucket, so Contains returns false even though equality says they match. The fix is to override GetHashCode to derive from the same fields Equals uses, here just Code.GetHashCode().
When an instance's hash changes after it's been added to a HashSet<T> or used as a Dictionary key, the collection loses track of it.
The same cart instance, the same set, but Contains flips from true to false because the lookup now hashes to a different bucket than where the cart was originally placed. The cart is still in the set (it's still in the table's internal array), but it's effectively unreachable through the public API.
Two fixes. The clean one is to make the fields used in hashing immutable: declare them get-only or init-only, so the hash never changes after construction. The other is to remove an instance from the collection before mutating it and re-add it afterward; this is fragile and easy to forget, so prefer immutability.
Transitivity says: if Compare(x, y) < 0 and Compare(y, z) < 0, then Compare(x, z) < 0. Breaking it produces a "comparer" that sorts incorrectly, and List<T>.Sort does not throw; it just gives wrong output.
The output looks plausible on this input, but the comparer claims A < Z, Z < B, and B < A, which is a cycle. Sort's internal algorithm relies on a total order; when one isn't present, the result is undefined. Different inputs will produce different (and equally wrong) outputs. The bug only shows up under specific data, which is why it can sit in code for a long time.
Tests for comparer correctness should include the three-element transitivity check: pick three values, compute all three pairwise comparisons, and confirm the signs are consistent.
The BCL's contract for Equals(object obj) is that obj may be null, and Equals(null) should return false. Skipping the null check causes a NullReferenceException from inside the equality method.
The safe shape uses pattern matching:
obj is Sku s returns false for null and false for the wrong type. Only when both checks pass does the && evaluate the field comparison. This single-line pattern handles both edge cases at once.
HashSet<T> and Dictionary<TKey, TValue> ask their comparer for both Equals and GetHashCode. If those two methods don't agree (equal objects produce different hashes, or unequal objects with the same hash never make it past the Equals scan), the collection misbehaves the same way as the override-Equals-but-not-GetHashCode bug.
Equals says "PRD-001" and "prd-001" are unequal. GetHashCode says they hash to the same value. Put Sku("PRD-001") in a HashSet<Sku> with this comparer, look up Sku("prd-001"), and the answer is "not found" because the Equals scan rejects the bucket match. That's confusing but at least consistent. The opposite bug (Equals says equal, hashes differ) is worse: the set will silently store duplicates.
The rule when writing an IEqualityComparer<T> is the same as when overriding on the type: pick one set of fields and one comparison rule, and apply it identically in both methods.
The four interfaces, side by side, with the rule of thumb for each.
| Interface | Implement when | Pass to |
|---|---|---|
IComparable<T> | The type has one natural order that's correct everywhere | Implicit: List<T>.Sort(), Array.Sort(), SortedSet<T>, SortedDictionary<TKey, TValue> (with no comparer) |
IComparer<T> | Order varies by context, or the type doesn't own a natural order | List<T>.Sort(comparer), SortedSet<T>(comparer), OrderBy(keySelector, comparer), PriorityQueue<TElement, TPriority>(comparer) |
IEquatable<T> | The type has one natural equality (often paired with Equals/GetHashCode overrides) | Implicit: List<T>.Contains, Dictionary and HashSet (when no comparer is supplied) |
IEqualityComparer<T> | Equality varies by context (case-insensitive in one place, case-sensitive elsewhere) | Dictionary<TKey, TValue>(comparer), HashSet<T>(comparer), Distinct(comparer), GroupBy(..., comparer), ToDictionary(..., comparer) |
Two practical guidelines. First, if you implement IComparable<T> and the result feels arbitrary (you can't pick between "by name" and "by price"), don't implement it; force callers to pass an explicit IComparer<T>. Second, if you override Equals, always override GetHashCode, and always implement IEquatable<T>. The three move together: the compiler's warnings exist because skipping any of them creates the bugs above.
Records make all of this easier when value-based equality is what you want. A record's compiler-generated Equals, GetHashCode, ==, and != are all consistent by construction. The cases where you still need to write equality by hand are when records' default field-by-field equality isn't right (case-insensitive SKU equality, equality on a subset of fields, equality that depends on a normalized value).