Skip to content

Equality, Hashing & Ordering Contracts

Key Points

  • Override Equals AND GetHashCode together — they're a contract: a.Equals(b) ⇒ a.GetHashCode() == b.GetHashCode(). Breaking it corrupts dictionaries and hash-sets.
  • Records auto-generate value equality + a stable hash code. Use them for DTOs and value objects.
  • IEquatable<T> avoids boxing on value types; always implement it alongside Equals(object).
  • IComparable<T> for natural ordering; IComparer<T> for pluggable orderings (multiple sort strategies). IComparable<T>.CompareTo must be a strict weak ordering — total, transitive, anti-symmetric.
  • Strings: StringComparer.Ordinal for keys; OrdinalIgnoreCase for case-insensitive; never rely on default culture-sensitive comparison for identifiers.

Concepts (deep dive)

The four equality APIs

object.Equals(object?)       — virtual, boxes value types
IEquatable<T>.Equals(T)      — generic, no boxing
operator ==                  — static, can be hidden by overload
ReferenceEquals(a, b)        — pure reference identity

object.Equals defaults to reference equality for classes and shallow field equality for value types (slow, uses reflection). Override both Equals and GetHashCode whenever you redefine equality.

GetHashCode contract

  • Equal objects must produce equal hash codes.
  • Different objects may collide; just keep collisions rare.
  • Must be stable for the lifetime the instance is in a hash table. Mutating fields used in the hash → corrupted dictionary.
  • Use HashCode.Combine(field1, field2, …) — handles spreading & seeding.
public override int GetHashCode() => HashCode.Combine(Id, Sku, Price);

Records do this for you

public record Money(decimal Amount, string Currency);
// Auto: Equals, GetHashCode, ==, != by structural equality on all positional members.

For mutable types, define equality manually — records' equality on mutable members can mismatch hashes after mutation.

IEquatable<T> to avoid boxing

public readonly struct OrderId(long value) : IEquatable<OrderId>
{
    public long Value { get; } = value;
    public bool Equals(OrderId other) => Value == other.Value;
    public override bool Equals(object? obj) => obj is OrderId o && Equals(o);
    public override int GetHashCode() => Value.GetHashCode();
    public static bool operator ==(OrderId a, OrderId b) => a.Equals(b);
    public static bool operator !=(OrderId a, OrderId b) => !a.Equals(b);
}

Without IEquatable<T>, Dictionary<OrderId, …> boxes on every key compare.

Equality vs identity

  • Identity: "are these the same instance?" → ReferenceEquals.
  • Value equality: "do they represent the same value?" → records, value objects.
  • Domain equality: entities are equal if their identity field matches; value objects compare all members. Don't conflate these in DDD models.

Ordering — IComparable<T> vs IComparer<T>

public record Order(DateTime Created) : IComparable<Order>
{
    public int CompareTo(Order? o) => o is null ? 1 : Created.CompareTo(o.Created);
}

// Plugged-in orderings
public sealed class ByTotalDesc : IComparer<Order>
{
    public int Compare(Order? x, Order? y) => (y?.Total ?? 0).CompareTo(x?.Total ?? 0);
}

CompareTo returns negative / zero / positive. Must satisfy trichotomy (exactly one is true: ab) and transitivity. Violating these breaks Sort, OrderBy, sorted collections.

EqualityComparer<T>.Default

Generic code resolves the right comparer:

public static bool Same<T>(T a, T b) => EqualityComparer<T>.Default.Equals(a, b);

It picks IEquatable<T>.Equals if available, else object.Equals. Same story for Comparer<T>.Default with IComparable<T>.

String comparison policy

StringComparer.Ordinal               byte-wise, fastest, identifier-safe
StringComparer.OrdinalIgnoreCase     case-folded ordinal
StringComparer.InvariantCulture      culture-neutral linguistic
StringComparer.CurrentCulture        user culture, locale-sensitive

Rule: identifiers, file paths, configuration keys → ordinal. Display ordering → culture. Never use default string.Equals(other) for security/identifier checks — it's ordinal but Compare defaults to current culture (Turkish-I bug).


Code: correct vs wrong

❌ Wrong: equality without hash code

public class Sku
{
    public string Code { get; init; } = "";
    public override bool Equals(object? o) => o is Sku s && s.Code == Code;
    // GetHashCode not overridden → falls back to reference, breaks dictionaries
}

var d = new Dictionary<Sku, int>(); d[a]=1; d[b]; (b equals a) → KeyNotFoundException.

✅ Correct

public sealed class Sku : IEquatable<Sku>
{
    public string Code { get; }
    public Sku(string code) => Code = code;
    public bool Equals(Sku? o) => o is not null && Code == o.Code;
    public override bool Equals(object? o) => Equals(o as Sku);
    public override int GetHashCode() => Code.GetHashCode(StringComparison.Ordinal);
}

❌ Wrong: mutable hash inputs

public class Tag { public string Name { get; set; } = ""; public override int GetHashCode() => Name.GetHashCode(); }
var s = new HashSet<Tag>(); s.Add(t); t.Name = "renamed"; s.Contains(t); // false

✅ Correct: hash on immutable fields only

Use init-only or readonly fields for any field that participates in the hash.


Design patterns for this topic

Pattern 1 — Value object as record struct

public readonly record struct Money(decimal Amount, string Currency);

Stack-allocated, value equality, no boxing in dictionaries (compiler emits IEquatable<Money>).

Pattern 2 — Strongly-typed ID

public readonly record struct CustomerId(Guid Value);

Prevents passing a ProductId where CustomerId is expected. Implements IEquatable<> and == automatically.

Pattern 3 — Pluggable comparer registry

public static class OrderComparers
{
    public static readonly IComparer<Order> ByDate = Comparer<Order>.Create((a, b) => a.Created.CompareTo(b.Created));
    public static readonly IComparer<Order> ByTotalDesc = Comparer<Order>.Create((a, b) => b.Total.CompareTo(a.Total));
}
list.Sort(OrderComparers.ByDate);

Pattern 4 — IEqualityComparer<T> for keys you don't own

public sealed class CaseInsensitiveOrderComparer : IEqualityComparer<Order>
{
    public bool Equals(Order? x, Order? y) => string.Equals(x?.Sku, y?.Sku, StringComparison.OrdinalIgnoreCase);
    public int GetHashCode(Order o) => StringComparer.OrdinalIgnoreCase.GetHashCode(o.Sku);
}
new Dictionary<Order, int>(new CaseInsensitiveOrderComparer());

Pros & cons / trade-offs

Approach Pros Cons
Record (class) Auto equality + hash Heap-allocated, equality includes all positional members
Record struct Value type, no GC Larger structs become expensive to copy; equality from compiler
Manual IEquatable<T> Full control More code; easy to break contract
IEqualityComparer<T> External equality without owning the type Must be passed everywhere; duplicates contract

When to use / when to avoid

  • Records for immutable DTOs and value objects.
  • IEquatable<T> on any type you put in Dictionary/HashSet keys.
  • External IEqualityComparer when you can't modify the type (third-party) or need multiple equality semantics.
  • Avoid redefining equality on entity types — DDD entities are equal by identity, not value.

Interview Q&A

Q1. Why must GetHashCode be consistent with Equals? A. Hash containers bucket by hash code; if equal objects hash differently, the lookup never visits the right bucket and Contains/TryGetValue returns false.

Q2. Why is overriding Equals without GetHashCode a compiler warning (CS0659)? A. The compiler can prove the contract is broken — keep the warning at error severity.

Q3. What does HashCode.Combine give over manual XOR? A. Spreading (avoids cluster collisions for sequential ints), per-process randomized seed (mitigates hash-flooding DoS), and tested arithmetic.

Q4. When does default struct equality become a problem? A. The default ValueType.Equals uses reflection — slow, allocates. Always implement IEquatable<T> on structs you compare or use as keys.

Q5. Why is string.Equals(other, StringComparison.Ordinal) preferred for keys? A. Linguistic comparison varies by culture (Turkish "I", German "ß"); ordinal compares code units, deterministic across locales, and faster.

Q6. What's the contract for IComparable<T>.CompareTo? A. Trichotomy (exactly one of <, =, > holds), transitivity, anti-symmetry, and consistency with Equals (sgn(a.CompareTo(b)) == -sgn(b.CompareTo(a))). Violating these can crash Sort.

Q7. When does record equality mislead? A. When a positional member is a reference type with reference equality (e.g., byte[]). The record compares by reference, not contents — surprising for "value types."

Q8. Why is Comparer<T>.Default preferred over hand-rolled comparison in generic code? A. It honors IComparable<T> if implemented, falls back to IComparable (boxing), and produces a clear InvalidOperationException if neither exists. Hand-rolling can silently miscompare.


Gotchas / common mistakes

  • Mutating fields used in GetHashCode after the object enters a hash container.
  • Implementing Equals but forgetting ==/!=, leaving them as reference equality.
  • record with byte[] fields — reference equality, not byte equality.
  • IComparable<T> that returns 1/-1 instead of Math.Sign(...) — fine in practice but confusing.
  • Using GetHashCode().ToString() as a stable identifier — hash codes are randomized per process in modern .NET.
  • Ignoring StringComparer.Ordinal for dictionary keys — Turkish-I and locale surprises.

Further reading