← Open in the full interactive course (progress tracking, search & more)

Equals and GetHashCode aren't two independent methods you can override at your leisure — they're one contract, and breaking it corrupts every hash-based collection silently.

The previous lesson ended on a cliffhanger: "a poorly-written GetHashCode() silently degrades dictionary performance." But there's a worse failure mode than slow — wrong. Consider this, with a plain custom class:

public class Point { public int X; public int Y; } var a = new Point { X = 1, Y = 2 }; var b = new Point { X = 1, Y = 2 }; Console.WriteLine(a.Equals(b)); // False — even though X and Y are identical! var seen = new HashSet<Point>(); seen.Add(a); Console.WriteLine(seen.Contains(b)); // False — b is treated as a completely different point

Two points holding the exact same coordinates, and C# insists they're not equal. This isn't a bug — it's the default, and it's actually the correct default for a plain class. But it means anyone using Point as a dictionary key, or checking for duplicates with a HashSet<Point>, is going to get quietly wrong results unless they understand exactly what Equals and GetHashCode promise — and how to override both, correctly, together.

In this lesson, you'll learn the full Equals/GetHashCode contract, why the two must always be overridden together, the difference between reference equality and value equality, and how records give you all of this automatically — connecting directly back to what you learned about records in Foundations.

What Is It?

The Simple Explanation

Equality answers "are these two things the same?" — but "the same" can mean two different things. Reference equality asks "are these literally the same object in memory?" Value equality asks "do these hold the same data, regardless of whether they're the same object?" Hashing is the mechanism from the previous lesson — turning a value into a bucket-selecting number — and it has to agree with whichever kind of equality your type uses, or every hash-based collection built on top of it (Dictionary<TKey,TValue>, HashSet<T>) breaks in subtle, hard-to-diagnose ways.

The Technical Definition

Every type in .NET inherits object.Equals(object?) and object.GetHashCode(). By default, for a plain reference type (a class), Equals performs reference equalitytrue only if both references point at the exact same object — and GetHashCode() derives from that same object identity. The Equals/GetHashCode contract, formally, requires:

Why Does It Exist?

The Problem

Recall exactly how a dictionary lookup works: it computes a hash code to find the right bucket, then calls Equals() to confirm the match among that bucket's candidates. Now imagine a type whose Equals() says two values are equal, but whose GetHashCode() gives them different hash codes:

// A BROKEN example — Equals overridden, GetHashCode left at the default. Don't write this. public class BrokenPoint { public int X, Y; public override bool Equals(object? obj) => obj is BrokenPoint p && X == p.X && Y == p.Y; // GetHashCode() NOT overridden — still uses reference-based identity! } var a = new BrokenPoint { X = 1, Y = 2 }; var b = new BrokenPoint { X = 1, Y = 2 }; Console.WriteLine(a.Equals(b)); // True — Equals says they're the same var set = new HashSet<BrokenPoint>(); set.Add(a); Console.WriteLine(set.Contains(b)); // False! — different hash codes send them to different buckets, // so Equals() never even gets called to compare them

a and b hash to different buckets (since GetHashCode() is still reference-based), so the dictionary or set never even looks in the bucket where a lives when searching for bEquals() is never given the chance to say "these match." The contract violation doesn't throw an exception; it just silently produces wrong answers, which is far more dangerous than a crash.

The Solution

The contract — "equal objects must produce equal hash codes" — exists precisely to guarantee this can never happen, as long as you honor it. Whenever you override Equals() to compare by value, you must override GetHashCode() to derive from those same values, so that any two objects Equals() considers equal are mathematically guaranteed to land in the same bucket, giving Equals() the chance to actually run and confirm the match.

Big Picture

REFERENCE EQUALITY vs VALUE EQUALITY
Reference equality (default, plain class)
"Are you literally the same object?"
Two separate new Point() calls → never equal, even with identical fields
Value equality (records, overridden classes, structs)
"Do you hold the same data?"
Two separate values with identical fields → equal, regardless of object identity
GetHashCode() must always agree with whichever notion of equality Equals() actually implements.

How It Works

OVERRIDING Equals AND GetHashCode CORRECTLY, STEP BY STEP
1. OVERRIDE Equals(object?) TO COMPARE THE RELEVANT FIELDS
public override bool Equals(object? obj) =>
    obj is Point other && X == other.X && Y == other.Y;
2. OVERRIDE GetHashCode() USING EXACTLY THE SAME FIELDS
public override int GetHashCode() => HashCode.Combine(X, Y);
3. OPTIONALLY OVERLOAD == AND != TO MATCH
public static bool operator ==(Point a, Point b) => a.Equals(b);
public static bool operator !=(Point a, Point b) => !a.Equals(b);
4. OR — LET THE COMPILER DO ALL OF THIS FOR YOU

Simple Example

public class Point { public int X { get; } public int Y { get; } public Point(int x, int y) { X = x; Y = y; } public override bool Equals(object? obj) => obj is Point other && X == other.X && Y == other.Y; public override int GetHashCode() => HashCode.Combine(X, Y); } var a = new Point(1, 2); var b = new Point(1, 2); // a different object, same data var c = a; // literally the same object Console.WriteLine(a.Equals(b)); // True — value equality, correctly overridden Console.WriteLine(ReferenceEquals(a, b)); // False — genuinely two different objects Console.WriteLine(ReferenceEquals(a, c)); // True — c IS a var seen = new HashSet<Point>(); seen.Add(a); Console.WriteLine(seen.Contains(b)); // True — now correctly recognized as a duplicate

Code → Meaning → Result:

Real-World Example

Deduplicating customer records — a genuinely common real-world task — depends entirely on getting equality right. Two CustomerRecord objects imported from different data sources, with the same email address, should be recognized as the same customer.

public class CustomerRecord { public string Email { get; } public string Name { get; } public CustomerRecord(string email, string name) { Email = email.ToLowerInvariant(); // normalize before comparing/hashing Name = name; } public override bool Equals(object? obj) => obj is CustomerRecord other && Email == other.Email; // dedupe by email only public override int GetHashCode() => Email.GetHashCode(); } List<CustomerRecord> imported = [ new CustomerRecord("Ana@Example.com", "Ana Lopez"), // from source A new CustomerRecord("ana@example.com", "A. Lopez"), // from source B — same person, different casing/name new CustomerRecord("ben@example.com", "Ben Cole") ]; var uniqueCustomers = new HashSet<CustomerRecord>(imported); Console.WriteLine(uniqueCustomers.Count); // 2 — the two Ana records correctly collapsed into one

Notice the equality here is deliberately narrower than "every field matches" — it's specifically "same email," because that's what actually identifies a unique customer for this business rule. This is the real lesson: you decide what "equal" means for your type, and both Equals and GetHashCode must consistently reflect that decision — here, only Email, not Name, participates in either method.

Analogy

A Filing Label and the Folder's Contents

Think of GetHashCode() as the label on a filing cabinet drawer, and Equals() as actually opening a folder to compare its full contents. If two folders truly hold the same information (they're "equal"), but someone mislabeled one of the drawers, a clerk searching by label will open the wrong drawer entirely and never even glance at the matching folder — the mismatch between the label (hash code) and the actual contents (equality) makes the folder unfindable, even though it's sitting right there in the filing cabinet.

That's the exact failure from the "Why Does It Exist?" section: an inconsistent GetHashCode() doesn't cause a crash — it causes a search that confidently looks in the wrong place and reports "not found," even when the match is right there.

Under the Hood

WHAT RECORDS GENERATE FOR YOU, AND WHY IT'S ALWAYS CONSISTENT
1. THE COMPILER GENERATES BOTH METHODS FROM THE SAME PROPERTY LIST
public record Point(int X, int Y);
// roughly compiles to:
// public override bool Equals(object? obj) => Equals(obj as Point);
// public bool Equals(Point? other) => other is not null && X == other.X && Y == other.Y;
// public override int GetHashCode() => HashCode.Combine(X, Y);
2. STRUCTS ALSO DEFAULT TOWARD VALUE EQUALITY — BUT SLOWLY, VIA REFLECTION
3. HashCode.Combine MIXES BITS TO REDUCE COLLISION CLUSTERING

Common Confusion

1. Equal hash codes do not mean equal objects — only the reverse is guaranteed

The contract is a one-way street: equal objects must produce equal hash codes, but two objects sharing a hash code are not necessarily equal — that's just an ordinary collision. Never write code that treats matching hash codes as proof of equality; always let Equals() make the final call, exactly as .NET's own collections do internally.

2. ReferenceEquals vs .Equals() vs == — three different questions

ReferenceEquals(a, b) always asks "are these literally the same object?" and can never be overridden — it's a static method with fixed behavior. .Equals() asks whatever question the type's override defines. == asks whatever question the type's operator == defines (or falls back to reference equality if none is defined). For a plain, un-overridden class, all three agree. Once you override Equals for value semantics, ReferenceEquals and .Equals() can legitimately disagree — that's expected, not a bug.

3. Reference equality is the correct default for entities — don't treat it as "the primitive version" that needs fixing

As the records lesson noted, a type representing an entity with real identity (a live database connection, a domain object tracked by a unique ID, where two objects with identical field values should still be treated as separate) genuinely wants reference equality. Overriding Equals for value semantics is a deliberate choice for data-shaped types, not a universal "best practice" every class should adopt.

Common Mistakes

Mistake 1 — Overriding Equals without overriding GetHashCode

Wrong — this is exactly the broken example from "Why Does It Exist?": Equals says two objects match, but they hash into different buckets, so hash-based collections never find the match. Most IDEs and analyzers flag this with a warning — never ignore it.

Correct — always override both together, using the same fields in each:

public override bool Equals(object? obj) => obj is Point p && X == p.X && Y == p.Y; public override int GetHashCode() => HashCode.Combine(X, Y); // same fields, every time

Mistake 2 — Basing GetHashCode on mutable fields, then mutating them after use as a dictionary key

This connects directly back to the previous lesson's warning — if GetHashCode() depends on a field that changes after the object is already stored as a key, the object becomes effectively unfindable. Prefer immutable fields (or init-only properties) for anything GetHashCode() depends on — again, exactly why records default toward immutability.

Mistake 3 — Forgetting that Equals must handle null and the wrong type gracefully

obj is Point other && ... looks obvious in hindsight, but a common bug is casting directly instead: var other = (Point)obj; return X == other.X; — this throws instead of returning false for a non-Point argument, and obj is Point already returns false safely for null. Always use a type-pattern check (is), never a direct cast, inside Equals.

When Should I Use It?

Override Equals/GetHashCode (or use a record) when

Leave the default (reference equality) when

Rule of thumb: If your type is fundamentally "some data" rather than "some identity," reach for a record and get a correct, consistent Equals/GetHashCode pair for free — hand-writing this pair is worth understanding deeply (as you just did), but rarely worth doing by hand in day-to-day code when records already exist.

Mental Model

The contract = equal objects MUST have equal hash codes — no exceptions, ever
Reference equality = "are you literally the same object?" — the default for plain classes
Value equality = "do you hold the same data?" — what records give you automatically

Remember:
· Always override Equals and GetHashCode together, using exactly the same fields in both.
· Equal hash codes don't prove equality — only the reverse direction is guaranteed.
· Records exist precisely to give you this contract correctly, without hand-writing it.

Key Takeaway


Check Your Understanding

You've learned why Equals and GetHashCode are one contract, not two independent methods. Let's check your understanding.

1. A class overrides Equals to compare by value, but leaves GetHashCode at its default (reference-based) implementation. What is the most likely consequence?

Show answer

Correct: B

Why B is correct: This is exactly the "Why Does It Exist?" scenario — the two hash-mismatched-but-equal objects land in different buckets, so a hash-based collection never even calls Equals() to compare them, silently producing wrong results.

Why A is incorrect: This compiles fine — most tooling issues a warning, but it's not a compile error.

Why C is incorrect: Equals() itself keeps working correctly on its own — the failure specifically shows up in hash-based collections, not in a direct a.Equals(b) call.

Why D is incorrect: No exception is thrown at all — this is precisely what makes the bug so dangerous; it fails silently rather than loudly.

Reinforcement: Overriding only one half of the pair produces a silent, hard-to-diagnose bug, not a compile error or an exception.

2. Two objects have the same hash code. What can you correctly conclude?

Show answer

Correct: B

Why B is correct: As covered in "Common Confusion," the contract only guarantees one direction — equal objects have equal hash codes. It says nothing about the reverse; a shared hash code is just as likely to be an ordinary collision as genuine equality.

Why A is incorrect: This reverses the actual guarantee — matching hash codes never prove equality on their own.

Why C is incorrect: Nothing about a shared hash code implies they're different — it's simply inconclusive either way.

Why D is incorrect: Sharing a hash code is completely normal and doesn't indicate anything is broken.

Reinforcement: A hash code match is a hint worth checking further, never proof of equality by itself.

3. Why does a record guarantee a correct Equals/GetHashCode pair, while hand-writing the two methods on a plain class does not?

Show answer

Correct: B

Why B is correct: As shown in "Under the Hood," because both synthesized methods are derived from the same positional parameter list, there's no way for them to end up using different fields — the consistency is structural, guaranteed by how the compiler generates the code.

Why A is incorrect: There's no special CPU instruction involved — this is purely a matter of the generated C#/IL code being consistent by construction, not a hardware difference.

Why C is incorrect: Records can still have custom logic layered on top if genuinely needed — the point is the generated default is already correct and consistent.

Why D is incorrect: Plain classes absolutely can override GetHashCode — that's exactly what this lesson demonstrated with the hand-written Point class; the risk is purely human error keeping the two methods in sync.

Reinforcement: The safety records provide comes from generating both methods from one shared source of truth, eliminating the human error of hand-syncing two separate overrides.

4. A type represents a live database connection, where two connection objects should always be treated as distinct even if they happen to point at the same database and settings. Which approach fits best?

Show answer

Correct: C

Why C is correct: As covered in "Common Confusion" and "When Should I Use It?", a type with genuine identity — like a live connection — should keep reference equality. Two connections with identical settings are still two separate, independently-managed resources; treating them as "equal" would be actively misleading.

Why A is incorrect: This would incorrectly conflate "same configuration" with "same connection," which is exactly the wrong notion of equality for an identity-bearing type.

Why B is incorrect: Records are excellent for data, but not a universal default — this lesson and the records lesson both explicitly warn against using them for identity-bearing types.

Why D is incorrect: This is the exact contract violation from Question 1, applied here for no benefit — there's no reason to override just one half when reference equality is already the correct choice.

Reinforcement: Overriding for value equality is a deliberate choice for data-shaped types — identity-bearing types are correctly served by the reference-equality default.

You now understand the full Equals/GetHashCode contract. Next: IComparable<T> — giving your own types a natural ordering that List<T>.Sort() and Array.Sort() can use directly.


dotnetmadeeasy.com — Learn C# and .NET, the right way.