Learning outcome
By the end of this lesson, you should be able to explain the contract between Equals and GetHashCode, distinguish true equality from a hash collision, and reason about why immutable value objects make reliable keys in hash-based collections. This is a common interview topic because it tests both language knowledge and practical collection behavior.
Intuition
Think of a hash code as a fast bucket hint, not as an identity proof. Hash-based collections use the hash to narrow the search space first, then they call equality inside the matching bucket.
That means two important things:
- If two values are equal, they must produce the same hash code.
- If two values produce the same hash code, they still might not be equal.
That second case is a collision. It is normal, and it does not break the contract.
A useful mental model is:
GetHashCodesays, “Where should I look?”Equalssays, “Is this the same logical value?”
That distinction matters when you use a type as a key in Dictionary<TKey,TValue> or HashSet<T>. The collection first groups by hash code and then uses equality within that group.
Deep dive
For a value object, equality should be based on the logical data the type represents, not on object identity. For an immutable Money value, two instances with the same currency and amount should compare equal even when constructed separately. The record struct below is a value type; reference identity is not its equality model.
In C#, the contract is simple:
- If
a.Equals(b)istrue, thena.GetHashCode()andb.GetHashCode()must return the same value. - If
a.GetHashCode()is the same asb.GetHashCode(), that does not provea.Equals(b).
Under one consistent, contract-correct comparer in the same execution, different hash codes rule out equality: this is the contrapositive of rule 1. In contrast, different values can have the same hash code, so matching hashes require an equality check. Do not compare hash codes from different comparers or processes as stable identities.
Equalsshould be reflexive, symmetric, transitive, and consistent.GetHashCodeshould use the same fields that participate in equality.
You often see this implemented manually for classes and structs, but modern C# also gives records built-in value equality. For interview purposes, you should still understand the manual implementation because it shows the underlying contract clearly.
A good immutable key type has readonly state and no setters. That prevents the value from changing after it has been inserted into a hash-based collection. If the logical value cannot change, the hash code cannot drift out from under the collection.
Failure modes
A few common mistakes come up repeatedly in interviews and production code:
- Overriding
Equalsbut notGetHashCode. - Using mutable fields in equality and then changing them after insertion into a
HashSetorDictionary. - Assuming same hash code means equal object.
- Accidentally comparing reference identity for a value object.
- Mixing comparer policy with type semantics: the type defines its own equality, but the collection may accept an external comparer that changes lookup behavior.
Another subtle point: RuntimeHelpers.GetHashCode is about object identity-oriented hashing, not logical value equality. That makes it different from an overridden GetHashCode on your type.
Interview drill
Use these as fast verbal checks:
- Why must equal objects have equal hash codes?
- Why are collisions allowed?
- What happens if
Equalssays two objects are equal butGetHashCodediffers? - Why is immutability useful for hash keys?
- How do records simplify equality for value-like data?
- What is the difference between object equality and collection comparer policy?
Revision checklist
- I can define the equality/hash-code contract in one sentence.
- I know that collisions do not imply equality.
- I can explain why keys should be immutable.
- I can describe how
DictionaryandHashSetuse hash codes plus equality. - I can implement a small value type with correct
EqualsandGetHashCode. - I can explain why a record type is often a good fit for value semantics.
Production code
The example below uses an immutable readonly record struct, which is a value type with built-in value semantics. It demonstrates three distinct cases:
- two equal values with the same hash code,
- two different values that may or may not collide,
- a collection lookup that depends on both hashing and equality.
Because collisions are allowed and are determined by the runtime implementation, the example does not pretend to force a specific collision. Instead, it checks the contract directly and also prints whether the third value happens to collide with the first value on this runtime.
Code walkthrough
The executable example compares three Money values. The first two are logically identical, so Equals returns true and their hash codes match. The third differs in amount, so Equals returns false.
The program then inserts all three values into a HashSet<Money>. The set count stays at 2, because the duplicate logical value is not added twice. Finally, the program reports whether the third value collided with the first one by comparing hash codes. If they match, that is a collision, but not equality.
That is the key interview idea: equal values must hash equally, but equal hashes do not prove equality.
Reference: Microsoft: Object.GetHashCode contract.
Executable code examples
Immutable value key with equality, hash-code checks, and collision awareness
Program.cs
using System;
using System.Collections.Generic;
public readonly record struct Money(string Currency, decimal Amount)
{
public override string ToString() => $"{Currency} {Amount:0.00}";
}
public static class Program
{
public static void Main()
{
var a = new Money("USD", 19.99m);
var b = new Money("USD", 19.99m);
var c = new Money("USD", 20.00m);
Console.WriteLine($"a.Equals(b): {a.Equals(b)}");
Console.WriteLine($"a hash == b hash: {a.GetHashCode() == b.GetHashCode()}");
Console.WriteLine($"a.Equals(c): {a.Equals(c)}");
Console.WriteLine($"a hash == c hash: {a.GetHashCode() == c.GetHashCode()}");
Console.WriteLine($"a hash == c hash means equal: {a.GetHashCode() == c.GetHashCode() && a.Equals(c)}");
Console.WriteLine($"a hash == c hash is just a collision check: {a.GetHashCode() == c.GetHashCode()}");
var set = new HashSet<Money> { a, b, c };
Console.WriteLine($"HashSet count: {set.Count}");
Console.WriteLine($"Contains USD 19.99: {set.Contains(new Money("USD", 19.99m))}");
Console.WriteLine($"Contains USD 20.00: {set.Contains(new Money("USD", 20.00m))}");
Console.WriteLine(a == b ? "Equality operator agrees" : "Equality operator disagrees");
}
}