C# Equals and GetHashCode: Equality and Hashing Explained
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 pointTwo 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 equality — true only if both references point at the exact same object — and GetHashCode() derives from that same object identity. The Equals/GetHashCode contract, formally, requires:
- If
a.Equals(b)istrue, thena.GetHashCode()must equalb.GetHashCode()— always, with no exceptions. - The reverse is not required — two objects can share a hash code without being equal (that's an ordinary collision, from the previous lesson).
GetHashCode()must return the same value for the same object as long as nothing relevant to equality has changed — it must be stable for the lifetime of that value's use as a key.
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 thema 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 b — Equals() 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
Two separate
new Point() calls → never equal, even with identical fields
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
public override bool Equals(object? obj) =>
obj is Point other && X == other.X && Y == other.Y;
- Check the type first (via the
ispattern, which also handlesnullgracefully —null is Pointisfalse), then compare every field that participates in equality.
public override int GetHashCode() => HashCode.Combine(X, Y);
HashCode.Combine(...), a built-in .NET helper, mixes multiple values into one well-distributed hash code — it's the standard, recommended way to do this, rather than hand-rolling your own combining logic.- Notice: exactly the same fields,
XandY, feed both methods. This is the part that's easy to get subtly wrong — adding a field toEqualswithout also adding it toGetHashCode(or vice versa) reintroduces the contract violation.
public static bool operator ==(Point a, Point b) => a.Equals(b);
public static bool operator !=(Point a, Point b) => !a.Equals(b);
- Without this step,
==on twoPointinstances still uses reference equality, even though.Equals()now uses value equality — a confusing inconsistency worth avoiding for any type meant to have value semantics.
- As you learned when records were introduced in Foundations,
public record Point(int X, int Y);generates a correctEquals,GetHashCode, and==/!=automatically — every field-by-field consistency detail above, handled 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 duplicateCode → Meaning → Result:
a.Equals(b)is nowtruebecause both methods were overridden consistently, using the same two fields.ReferenceEqualsstill correctly distinguishes "same object" from "equal value" — overridingEqualsdoesn't erase that distinction, it just changes what.Equals()itself reports.HashSet<Point>.Containsnow correctly recognizesbas a duplicate ofa— exactly the fix the broken example earlier was missing.
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 oneNotice 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
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
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);
- Because both methods are synthesized from the exact same positional parameter list, they can never drift out of sync the way hand-written overrides can — the compiler guarantees the contract holds, by construction.
- Unlike classes, a plain
struct's inheritedEqualsalready compares field-by-field — but the default implementation does so via reflection, which is measurably slower than a hand-written or record-generated comparison. This is exactly whyrecord struct(also from Foundations) matters: it generates the same fast, direct field comparison a class-based record does, instead of falling back to reflection.
- Simply adding field hash codes together (
X.GetHashCode() + Y.GetHashCode()) tends to produce poorly-distributed results — many different combinations end up landing in the same buckets.HashCode.Combineuses a proper mixing algorithm specifically designed to spread combined values well, which is why it's the recommended approach over ad-hoc combining.
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 timeMistake 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
- The type represents data you'll compare by content — a coordinate, a money value, a DTO, anything you'll put in a
HashSet<T>or use as a dictionary key based on its value. - You're deduplicating records from an external or untrusted source, as in the customer example.
Leave the default (reference equality) when
- The type represents identity — a service, a live connection, an entity where "same data" shouldn't mean "same thing."
- You have no need to compare instances for equality at all — don't add the override speculatively.
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
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
- The Equals/GetHashCode contract: objects that
Equals()says are equal must always produce the same hash code — violating this silently breaks every hash-based collection. - Reference equality ("same object?") is the default for plain classes; value equality ("same data?") requires overriding both methods together, or using a record.
- Always use the same set of fields in both
EqualsandGetHashCode, combined viaHashCode.Combine(...)for well-distributed results. - Records generate a correct, always-in-sync
Equals/GetHashCodepair automatically — the compiler-generated version can never drift out of sync the way hand-written overrides can. - Choose reference equality for identity-bearing types, value equality for data-shaped types — it's a deliberate design decision, not a universal default to apply everywhere.
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.