Last Updated: May 17, 2026
C# gives every type two equality methods inherited from object: Equals and GetHashCode. For reference types, the defaults compare whether two variables point to the exact same object in memory, which is rarely what a domain model wants. This chapter shows when and how to override both methods correctly, why they always travel as a pair, and what breaks when the contract is violated.
Consider a simple Product class with a SKU (the store's internal code that uniquely identifies what's being sold) and a name. Two Product instances with the same SKU describe the same product, so a reasonable program would treat them as equal.
The default Equals does not agree:
Both objects describe the same SKU, yet Equals returns false. That's because the default object.Equals for a reference type is reference equality: are a and b the same object on the heap? They aren't, so the answer is false.
This is the right default for many types. Two Stream instances pointing at the same file are still two different streams. Two HttpClient objects are not interchangeable. But for value-like types (a product, a piece of money, a coordinate, a date range), what matters is the data, not the memory address.
C# lets a type opt into value equality by overriding Equals and GetHashCode. The rest of this chapter walks through exactly how to do that, and the contract you have to honor.
The distinction comes up enough that it has names:
| Term | Meaning | Default for |
|---|---|---|
| Reference equality | Two variables point to the same object on the heap | Reference types (class) |
| Value equality | Two objects hold the same data, regardless of location | Value types (struct), string, record |
string is a reference type but already uses value equality, which is why "hi" == "hi" returns true even when the literals come from different places. The BCL overrode Equals and GetHashCode on string for exactly this reason.
Before writing any override, know the rules. The .NET docs lay out five guarantees that any correct Equals implementation must keep. Skipping any of them breaks collections, LINQ, and code other engineers write trusting these rules.
| Rule | What it means |
|---|---|
| Reflexive | x.Equals(x) is always true. An object equals itself. |
| Symmetric | If x.Equals(y) is true, then y.Equals(x) is also true. |
| Transitive | If x.Equals(y) and y.Equals(z) are both true, then x.Equals(z) is true. |
| Consistent | Repeated calls return the same result as long as the objects don't change. |
| Null-safe | x.Equals(null) returns false. It never throws. |
These rules are not suggestions. The runtime depends on them. Dictionary<TKey, TValue> assumes that if it puts a key in a bucket and asks again later "is this the same key?", the answer will be consistent. LINQ's Distinct, Contains, Union, and Intersect all rely on the same guarantees.
The diagram below shows how the four object-to-object rules connect. Reflexive is the loop at the top, symmetric is the two-way arrow, and transitive is the chain that closes the triangle.
The diagram is a check sheet, not a math proof. Look at your override and ask: does it keep all four arrows true? Most accidental bugs (typos in field comparisons, comparing only some fields one way, forgetting null) show up as one of these arrows failing.
The null rule deserves a sentence on its own. The framework's collections call Equals on stored items without knowing whether the argument might be null. If your override throws on null instead of returning false, those collections crash on lookup.
GetHashCode returns an int that summarizes the object. Hash-based collections (Dictionary<TKey, TValue>, HashSet<T>, Hashtable) use this number to pick a storage bucket. Compute the hash, mod it by the number of buckets, and that's where the item lives.
The contract has exactly one ironclad rule:
If two objects are equal according to `Equals`, they must return the same hash code from `GetHashCode`.
The reverse is not required. Two unequal objects can return the same hash code (that's called a hash collision, and collections handle it). But two equal objects must never disagree on their hash code, because if they did the dictionary would put one of them in bucket A and look for the other in bucket B.
Three additional rules that follow from this:
obj.GetHashCode() on the same unchanged object must return the same number.int range. A GetHashCode that returns 0 for everything is technically legal, but it forces the dictionary into a single bucket and turns O(1) lookups into O(n).Whenever you override Equals, you must also override GetHashCode. The C# compiler enforces this with warning CS0659:
'Type' overrides Object.Equals(object o) but does not override Object.GetHashCode()
It's a warning, not an error, so a careless build still ships. The bugs that follow are nasty and silent.
A concrete failure is worth more than the rule book. Here is Product with a correct Equals override and a broken GetHashCode (it uses the default, which is reference-based).
Equals reports the two products as equal, but HashSet<Product> still accepts both. The set ends up with two entries that compare as equal to each other. The lookup set.Contains(p2) returns false even though an "equal" object is in the set.
The reason is in the diagram below. HashSet<T> first asks each object for its hash code to pick a bucket. Because GetHashCode was not overridden, p1 and p2 get different reference-based hashes, so they land in different buckets. The set's Contains looks in p2's bucket, doesn't find anything, and answers false.
The fix is to override GetHashCode so equal SKUs produce equal hashes:
With that line added, both products hash to the same bucket, the set sees the duplicate, and Contains returns true. The set never grows beyond one entry.
The same failure shows up in Dictionary<Product, int>, HashSet<Product>, LINQ's Distinct, and any third-party library built on these primitives. None of them log a warning when it happens. The data just goes missing.
Every correct Equals override for a reference type follows the same five-step shape. Knowing the shape by heart makes it easy to write and easy to review.
Each step earns its place:
x.Equals(null) returns false. Handle it first so the rest of the body can assume obj is not null.ReferenceEquals is a static method on object that never calls any override and never throws. It's an early-exit optimization, not a correctness step, but it's the kind of micro-optimization that's free, so it's part of the standard recipe.obj.GetType() != GetType() (rather than obj is Product) prevents a subclass from comparing equal to its parent, which would break symmetry. More on this below.(Product) cast, not as.Product, that's just Sku. For a Coordinate, it might be Latitude and Longitude. For Money, it's Amount and Currency.Here is Product with the full recipe and a matching GetHashCode:
Notice that a and b are considered equal even though their names and prices differ. The SKU is the identity, and that's the only field the override compares. That's a deliberate choice. The store's catalog might update the display name or price of "BOOK-001" over time, but it's still the same product.
Cost: Each call to GetHashCode runs once per dictionary or set operation. Keep it cheap. Avoid loops, file reads, or any work that allocates. The whole point of the hash is to make lookups fast.
obj.GetType() != GetType() Instead of is ProductA Premium Product subclass that adds LoyaltyMultiplier should never compare equal to a plain Product, even if both have the same SKU. Using obj is Product would let premium.Equals(product) succeed (because the premium IS a product), but product.Equals(premium) would fail at field comparison, violating symmetry.
obj.GetType() != GetType() enforces "same concrete runtime type on both sides." It's the safest choice when the type isn't sealed. If you have a sealed class, obj is Product is fine because there are no subclasses to worry about.
Manually combining hash codes used to involve magic numbers like:
That works, but the prime numbers and unchecked arithmetic feel like trivia. .NET added HashCode.Combine (in System.HashCode) to replace this pattern. It takes up to eight values and produces a good hash:
This is what production code should use. The implementation inside the BCL is the xxHash algorithm with a random seed, so collisions are well-distributed and the seed makes it harder to construct adversarial inputs.
When the type has more than eight fields, or fields that contribute conditionally, use HashCode as a builder:
The Add and ToHashCode form lets you fold any number of values in.
Remember the rule: the fields you fold into the hash must match the fields Equals compares. If Equals compares only Sku, then GetHashCode should only use Sku. If Equals compares Sku and Name, both go into the hash. The two methods are joined at the hip.
By default, == between two reference types compares references, the same as the default Equals. Once you override Equals, you usually want == to follow suit, otherwise users of your class get two different answers from two operations that look interchangeable.
Overloading the operators requires both == and != (they come as a pair):
A few details to notice. Both parameters are nullable (Product?) because either side of == might be null in user code. The body handles left is null explicitly so the call to left.Equals(right) is safe. And != is just the negation of ==, which keeps them consistent for free.
The full picture for the running Product example now looks like this:
When you should override ==:
Product, Money, Coordinate, DateRange) where users naturally expect == to compare data.Equals and == give different answers, which is confusing.When to leave == alone:
Stream, HttpClient, mutable services). For these, reference equality is the right behavior, so don't override Equals or ==.Equals is virtual and does. If subclasses care, prefer Equals alone.The standard object.Equals(object? obj) takes its argument as object, which means a comparison forces a runtime type check and an unbox/cast. For value-type equality this can be slow.
C# offers IEquatable<T>, a generic interface with a strongly typed Equals(T? other) method. Hash-based collections check whether your type implements it and prefer the typed method when available.
Two things change. The new Equals(Product? other) skips the runtime type check because the type is known. The old object-typed override now delegates to it through obj as Product. Both paths reach the same comparison.
The takeaway here is that IEquatable<T> is the strongly typed sibling of Equals, and implementing it on value-like types is a small win that the BCL collections will pick up automatically.
Structs work differently from classes. ValueType.Equals (the override that struct inherits) already does field-by-field comparison using reflection. So out of the box, two Point structs with the same coordinates compare equal:
The catch is reflection. The default ValueType.Equals inspects fields at runtime through reflection, which is dramatically slower than a direct field comparison. Same story for ValueType.GetHashCode: it walks fields via reflection, and historically returned the hash of the first field only on many runtimes.
For any struct that ends up in a Dictionary, a HashSet, or a tight loop, override both methods with a typed implementation:
Cost: The default ValueType.Equals and GetHashCode use reflection. Overriding both with a direct, typed implementation can speed up dictionary lookups by 10x to 100x depending on field count.
Records bring value equality by default. This is exactly the Equals and GetHashCode work this chapter has been doing, generated by the compiler.
A quick reminder:
The compiler generated Equals, GetHashCode, ==, and != for you. The catch: record equality compares all declared properties. If a type's identity should depend on only some fields (the Product example where the SKU alone defines identity), records are the wrong tool. Override Equals and GetHashCode manually on a regular class instead, or use a record with an explicit override (which works but defeats the purpose).
A rule of thumb:
| Need | Best fit |
|---|---|
| All fields define identity, immutable data | record |
| Only some fields define identity (like a SKU) | class with manual override |
| High-performance struct used in collections | struct with manual override and IEquatable<T> |
| Mutable service or resource (no value identity) | class with default equality (no override) |
A few mistakes that come up over and over again:
Using mutable fields in GetHashCode. If a property that contributes to the hash can change after the object is stored in a dictionary, the dictionary breaks. The object now hashes to a different bucket than the one it was filed under, and Contains returns false even though the object is right there.
The fix is to base equality and the hash on immutable fields. Make the relevant properties init-only or readonly, or use record if all fields are immutable.
Overriding Equals without GetHashCode (or vice versa). The CS0659 warning catches half of this. The other half (overriding GetHashCode only) doesn't even warn. Both methods must move together. If one is overridden, override the other in the same commit.
Forgetting the null check. A common shape is return Sku == ((Product)obj).Sku; with no null guard. This throws NullReferenceException on x.Equals(null), violating the contract and crashing collection code.
Comparing with `is Product` in an unsealed class. As discussed earlier, this breaks symmetry across subclasses. Use obj.GetType() != GetType() unless the type is sealed.
Hashing with `^` (XOR) of every field. XOR is symmetric, so Hash(a, b) equals Hash(b, a). Coordinate(3, 5) and Coordinate(5, 3) get the same hash, which spikes collisions in spatial datasets. Use HashCode.Combine instead.
A full, production-quality Product class that uses every recipe in this chapter:
The catalog has two entries even though three were added, because a and b share a SKU and the HashSet rejected the second one. The override stayed under twenty lines and handled nulls, type checks, hash consistency, and operators. That is the whole canonical shape.
Equals for a reference type is reference equality. Override it when the type's identity is its data, not its memory address.Equals contract requires reflexivity, symmetry, transitivity, consistency, and null safety. Breaking any rule makes the type unreliable in BCL collections.GetHashCode whenever you override Equals. Equal objects must produce equal hash codes, or hash-based collections silently lose data.ReferenceEquals shortcut, type check with GetType(), cast, compare fields. Use HashCode.Combine(...) for the hash.== and != together when the class is value-like and you want consistent behavior between Equals and the operators. Skip them for reference-identity types.IEquatable<T> on types used heavily in collections to avoid the cost of the object-typed Equals. Structs benefit the most because the default ValueType.Equals uses reflection.Equals, GetHashCode, ==, and != automatically, comparing all declared properties. Use them when all fields define identity. Reach for a regular class when only a subset of fields does.