Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close
    Cheat sheets: all cheat sheets

    Equality and Hash-Code Contracts for Immutable C# Value Keys

    Keep equality and hash-code contracts consistent.

    Learning outcome

    By the end of this note, 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:

    • GetHashCode says, “Where should I look?”
    • Equals says, “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.

    Analogy and its limit

    Imagine filing index cards into drawers by the first letter of the surname printed on each card. Cards with identical details go to the same drawer, but many different cards share that drawer. You still compare the full details before treating two cards as duplicates.

    Limit: the drawer is only a search shortcut. A real hash collection uses its configured hashing and equality rules; sharing a drawer, or a hash, does not establish equality.

    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:

    1. If a.Equals(b) is true, then a.GetHashCode() and b.GetHashCode() must return the same value.
    1. If a.GetHashCode() is the same as b.GetHashCode(), that does not prove a.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.
    1. Equals should be reflexive, symmetric, transitive, and consistent.
    1. GetHashCode must agree with the equality policy: equal values must hash equally. Combining equality-relevant fields with matching comparison or normalization rules is a sound implementation strategy. Using every equality field is not a contract requirement; omitting some can remain correct but increase collisions. Do not let data ignored by equality make equal values receive different hashes.

    The same rule applies when a collection receives an IEqualityComparer<T>: compare the results of that comparer’s Equals and GetHashCode, rather than mixing them with a different equality policy.

    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 reliable immutable key keeps its equality-relevant state stable after construction. 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 Equals but not GetHashCode.
    • Using mutable fields in equality and then changing them after insertion into a HashSet or Dictionary.
    • 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.

    Quick cheat sheet

    • Equal values: must have equal hashes under the same policy.
    • Equal hashes: still require an equality check; collisions are allowed.
    • Different hashes: rule out equality only under one consistent, contract-correct policy in the same execution.
    • Good keys: keep equality-relevant state stable while stored.
    • This example: a and b form one logical value; c adds a second. The set count is 2.

    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 Dictionary and HashSet use hash codes plus equality.
    • I can explain the equality checks in the Money example.
    • 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.

    Executable code examples

    Run this example

    Use the .NET 10 SDK. In a new empty folder, save the project below as Example.csproj and the listing as Program.cs. Run dotnet run --project Example.csproj.

    XML
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>disable</ImplicitUsings>
        <Nullable>enable</Nullable>
      </PropertyGroup>
    </Project>

    Immutable value key with equality, hash-code checks, and collision awareness

    Program.cs

    C#
    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");
        }
    }

    Expected output

    One permitted output is shown below. Both collision-check lines may instead be True, but they must agree. Equal hashes never override the False equality result for a and c.

    Text
    a.Equals(b): True
    a hash == b hash: True
    a.Equals(c): False
    a hash == c hash: False
    a hash == c hash means equal: False
    a hash == c hash is just a collision check: False
    HashSet count: 2
    Contains USD 19.99: True
    Contains USD 20.00: True
    Equality operator agrees

    Interview drill with answers

    Try each question before reading its answer. Questions about a separate fixture are labeled; they are not edits to the main program.

    1) Which two printed checks test the equal a/b pair, and what do they print?

    Answer: a.Equals(b): True and a hash == b hash: True. Both constructors use "USD", 19.99m. The first line checks equality; the second independently checks the hashes for that same pair. One line alone would omit half of this example’s contract check.

    2) Suppose a and c happen to have matching hashes. Which displayed Boolean result must still be False?

    Answer: a hash == c hash means equal remains False: its expression is (hashes match) && a.Equals(c), and the shown a.Equals(c) result is False. Both collision-check lines would be True together. This is one of the permitted outputs described above.

    3) After a future edit, imagine a.Equals(b) prints True but the next hash comparison prints False. What would that pair establish?

    Answer: It would be one counterexample to the contract stated above: this pair would be declared equal while receiving different hashes in the same run. That is enough to fail this check; finding other pairs that pass would not remove the counterexample. This is a hypothetical test diagnosis, not observed output.

    4) Which Money data differs for c, and does the shown program change any Money field between insertion and lookup?

    Answer: Currency stays USD; Amount is 20.00m for c rather than 19.99m. The listing contains no field reassignment between the set initializer and the two Contains calls. Its lookup demonstration therefore uses unchanged input values, rather than testing a mutation after insertion.

    5) Which member does Money implement explicitly, and which equality operations does Main exercise without handwritten bodies in that declaration?

    Answer: The declaration explicitly implements only ToString. Main still calls Equals and GetHashCode and evaluates a == b in the example. This is why the listing demonstrates the record’s behavior without presenting a manual equality implementation.

    6) For a thought experiment, define a policy that groups Money values by Currency alone and assigns hash 0 to every value. How many groups do a, b and c form, and does the program's HashSet test that policy?

    Answer: One group: all three constructor calls use USD, and this stipulated policy ignores Amount. The set count of 2 belongs to the original program, whose constructor supplies no explicit custom comparer. It does not test this thought experiment. Naming the equality policy is therefore necessary before transferring a count prediction.

    Optional video

    Dev Leader’s record equality walkthrough (14:37) is a free supplement. The examples use record classes; return to this note for record structs and hash-key contracts. A List<string> member’s behavior does not generalize to every reference type: strings have value equality.

    Practice test

    Continue with Immutable value keys: 12-question practice. This is the existing free companion test, with answer explanations. Try it after the worked example, then use the cheat sheet to review any gaps.

    Practice

    TechInterviewNotes Editorial Team · Updated 25 Oct 2026 ·