The records post let the compiler write equality for you. This post is about what it wrote and why, because sooner or later you will need to write it by hand, for a class you cannot convert to a record, an EF Core entity, or a type where equality means something specific like “same ISBN, ignore everything else”. Getting it slightly wrong produces a genuinely nasty bug: an object you put into a Dictionary that can never be found again.
Four ways to compare, and they do not agree
var a = new Book(1, "1984", 15);
var b = new Book(1, "1984", 15);
a == b // depends on whether == is overloaded
a.Equals(b) // depends on whether Equals is overridden
ReferenceEquals(a, b) // always false, two objects
object.Equals(a, b) // static helper: null-safe, then calls a.Equals(b)
The defaults, from the value types post:
- Reference types compare identity. Same object or not.
- Value types compare contents, field by field.
stringis a reference type that compares contents, because==is overloaded.- Records compare contents, because the compiler generates it.
So == on your own class asks “is this literally the same object?” and that is frequently not the question you meant.
The distinction that matters: identity vs equivalence
Before any code, decide which one your type has.
Identity. Two objects are the same thing only if they are literally the same instance, or share a stable unique key. A Customer in a database, an EF Core entity, a running HttpClient. Two customer objects with identical names are two different customers.
Equivalence. Two objects are interchangeable if their contents match. A Money, a DateRange, a DTO, an ISBN. Nobody cares which instance they hold.
Types with identity should keep the default reference equality. Types with equivalence should override it, and ideally be records. Overriding equality on an entity is where most equality bugs originate, because the object’s contents change while its identity does not.
Overriding Equals
Here is the complete, correct implementation for a class:
public sealed class Isbn : IEquatable<Isbn>
{
public string Value { get; }
public Isbn(string value) => Value = value;
public bool Equals(Isbn? other) // 1. the strongly typed one
{
if (other is null) return false;
if (ReferenceEquals(this, other)) return true; // cheap early exit
return string.Equals(Value, other.Value, StringComparison.Ordinal);
}
public override bool Equals(object? obj) => Equals(obj as Isbn); // 2. the object one
public override int GetHashCode() => // 3. MUST accompany Equals
StringComparer.Ordinal.GetHashCode(Value);
public static bool operator ==(Isbn? a, Isbn? b) => // 4. keep == consistent
a is null ? b is null : a.Equals(b);
public static bool operator !=(Isbn? a, Isbn? b) => !(a == b);
}
Four pieces, each with a job:
IEquatable<T>.Equals(T)is the typed version. Implementing it avoids boxing for structs and avoids a cast for classes, andDictionary,List.Containsand LINQ all look for it first.Equals(object?)is the one the runtime and older APIs call. It must agree with the typed version.GetHashCode()is not optional: see the next section.==and!=should be overloaded so all three comparison styles agree. If you overrideEqualsbut leave==alone,a.Equals(b)is true whilea == bis false, and readers will not notice which one a given line uses.
The compiler warns if you override Equals without GetHashCode. Do not suppress it.
Why GetHashCode must match Equals
This is the part that causes data loss, so it is worth understanding rather than memorising.
A Dictionary<TKey, TValue> and a HashSet<T> do not scan their contents. They call GetHashCode() on the key, use the result to pick a bucket, and only then compare with Equals against the few items in that bucket. That is what makes lookups O(1).
So the contract is:
If two objects are equal, they must return the same hash code.
The converse is not required: two unequal objects may share a hash code, which is a collision, and the dictionary resolves it with Equals.
Break the contract and here is what happens:
var set = new HashSet<Isbn>();
set.Add(new Isbn("978-0451524935"));
set.Contains(new Isbn("978-0451524935")); // false, if GetHashCode was not overridden
The two ISBNs are Equals, but the default GetHashCode returned different values based on object identity, so the lookup went to a different bucket and never even reached the Equals comparison. The item is in the set, and you cannot find it. No exception, no warning, just a value that has vanished.
Writing it is easy: HashCode.Combine does everything, including mixing well:
public override int GetHashCode() => HashCode.Combine(Id, Title, Price);
Three rules for GetHashCode:
- Use exactly the fields
Equalsuses. Not more, not fewer. - Never use a mutable field. Which is the next section.
- Do not try to be clever.
HashCode.Combinehandles up to eight values, andHashCode.Addin a loop handles more. Hand-rolledx * 31 + yschemes are worse and easy to get wrong.
The mutable key bug
This one is worth remembering because it survives code review:
public class Book
{
public string Title { get; set; } // mutable
public override bool Equals(object? o) => o is Book b && b.Title == Title;
public override int GetHashCode() => Title.GetHashCode();
}
var book = new Book { Title = "1984" };
var set = new HashSet<Book> { book };
book.Title = "Animal Farm"; // hash code just changed
set.Contains(book); // FALSE, it is in the set, holding a reference to it
set.Remove(book); // fails
The object sits in the bucket chosen by its old hash. Nothing recalculates it. The set now contains an item that cannot be found, cannot be removed, and will still be enumerated by a foreach. Every dictionary and hash set in the process holding that key is silently corrupted.
The fix is the general rule: hash on immutable data only. In practice that means value-equality types should be immutable, which is exactly why records make their properties init-only, and why string’s immutability makes it a perfect key.
Structs: override equality even though it works
A struct gets value equality for free, so this compiles and prints True:
public struct Point { public int X; public int Y; }
new Point { X = 1 } .Equals(new Point { X = 1 }) // True
But the default implementation uses reflection to walk the fields, which is dramatically slower than a direct comparison, and it boxes when called through Equals(object). For any struct used in a collection or a hot path, implement IEquatable<T> explicitly, or just declare it a readonly record struct and get a fast generated version.
IComparable, for ordering rather than equality
Equality answers “same or not”. Ordering answers “which comes first”, and it is a separate interface:
public class Book : IComparable<Book>
{
public int Price { get; init; }
public int CompareTo(Book? other) =>
other is null ? 1 : Price.CompareTo(other.Price);
}
CompareTo returns negative for “before”, zero for “same position”, positive for “after”. Implementing it makes list.Sort(), Array.Sort and SortedSet<T> work.
Two rules. Keep it consistent with Equals: if CompareTo returns 0, Equals should be true, or sorted collections behave oddly. And when the ordering is not intrinsic to the type (“sometimes by price, sometimes by title”), do not implement IComparable at all; pass an IComparer<T> or use OrderBy(b => b.Price) from the LINQ post, which is what you want almost every time.
Comparing without owning the type
When you cannot change a type but need different equality for one operation, pass a comparer:
public class BookTitleComparer : IEqualityComparer<Book>
{
public bool Equals(Book? a, Book? b) =>
string.Equals(a?.Title, b?.Title, StringComparison.OrdinalIgnoreCase);
public int GetHashCode(Book book) =>
StringComparer.OrdinalIgnoreCase.GetHashCode(book.Title);
}
var unique = books.Distinct(new BookTitleComparer()).ToList();
var index = new Dictionary<Book, int>(new BookTitleComparer());
The same hash/equals contract applies inside the comparer. This is also how you get case-insensitive dictionary keys, and it is worth knowing the built-in shortcut:
var lookup = new Dictionary<string, Book>(StringComparer.OrdinalIgnoreCase);
That single argument removes a whole family of casing bugs, and it is far better than lowercasing keys before insertion, which, as the strings post explained, is both slower and culture-dependent.
The same idea inside Java, Python and Go
The hash/equals contract is not a C# quirk. Every language with hash-based collections has the same rule and the same bug.
Java (Netflix, Android). equals() and hashCode(), with the identical contract and the identical failure mode of an object lost in a HashMap. Java’s version is worse in one respect: == on objects always compares references and cannot be overloaded, so forgetting .equals() on strings is a classic Java bug that C# avoids by overloading ==.
Python. __eq__ and __hash__, and the language enforces the relationship: define __eq__ on a class and Python sets __hash__ to None, making instances unhashable, so you cannot accidentally put a broken key in a dict. That is stricter than C#, which only warns. Python’s @dataclass(frozen=True) is the record equivalent, generating both.
Kotlin (Google). data class generates equals/hashCode from the primary constructor properties, the same as C# records, and the Kotlin docs carry the same warning about mutable properties in a hash key.
Go (Google). Struct comparison with == is built in and compares fields, but only if every field is comparable; map keys must be comparable types, checked at compile time. Go removes the whole class of bug by refusing to let you define custom equality at all.
Rust. PartialEq and Hash are separate traits, and the standard library documents that a == b must imply hash(a) == hash(b). #[derive(PartialEq, Hash)] generates both together, which makes them hard to get out of sync, the same insight records encode.
Summary
- Decide first whether your type has identity (keep reference equality) or equivalence (override it). Entities have identity; values have equivalence.
==compares references for classes by default, contents for structs, and contents forstringand records.- To override equality on a class, implement
IEquatable<T>.Equals(T),Equals(object?),GetHashCode(), and==/!=, all consistent with each other. - If two objects are equal they must return the same hash code. Breaking this makes items unfindable in a
DictionaryorHashSet, silently. - Hash only immutable fields. Mutating a key after insertion corrupts the collection permanently.
- Use
HashCode.Combine(...)rather than hand-rolled arithmetic. - Structs get value equality by default, but the default uses reflection; implement
IEquatable<T>or use areadonly record struct. IComparable<T>is for ordering, not equality, and should be consistent withEquals. For situational ordering, preferOrderByor anIComparer<T>.IEqualityComparer<T>supplies alternative equality for a type you do not own;StringComparer.OrdinalIgnoreCaseis the common ready-made one.
Interview questions and answers
1. What is the difference between == and Equals?
== is a static operator resolved at compile time from the declared types, and for reference types it compares references unless the type overloads it. Equals is a virtual method resolved at runtime on the actual object, so an override is always used. If you override Equals without overloading ==, the two disagree, which is why they should be implemented together.
2. Why must you override GetHashCode when you override Equals?
Because hash-based collections use the hash code to choose a bucket and only compare with Equals inside that bucket. If two equal objects return different hash codes, they land in different buckets and are never compared, so a Dictionary or HashSet will fail to find an item that is genuinely present.
3. What is the contract between Equals and GetHashCode?
Equal objects must return equal hash codes. Unequal objects may return the same hash code: that is a collision, and it is resolved by calling Equals. The hash code must also remain stable for as long as the object is used as a key.
4. What happens if you mutate an object used as a dictionary key?
Its hash code changes, but it stays in the bucket chosen from the old hash. Lookups compute the new hash, look in the new bucket, and find nothing, so Contains and Remove fail while foreach still returns the item. The entry is effectively lost. Hash only immutable state to avoid it.
5. What is IEquatable and why implement it?
A strongly typed equality interface. Collections and LINQ prefer it over Equals(object), which avoids a cast for classes and avoids boxing for structs, and it makes the intended equality explicit in the type’s signature. Implementing it means also overriding Equals(object) so both paths agree.
6. How does equality work for structs by default?
Structs compare field by field, so they have value equality out of the box. However, the default ValueType.Equals uses reflection over the fields, which is slow, and calling it through Equals(object) boxes the struct. Implement IEquatable<T> or declare a readonly record struct for a fast generated comparison.
7. What is the difference between IComparable and IEquatable?
IEquatable<T>.Equals answers whether two instances are equal. IComparable<T>.CompareTo answers ordering, returning a negative number, zero, or a positive number. Sorting needs comparison; hashing and lookups need equality. They should be consistent: CompareTo returning zero should mean the objects are equal.
8. What is IEqualityComparer used for?
Supplying equality and hashing from outside a type, for cases where you do not own the type or need different semantics per operation. Pass it to a Dictionary, HashSet, Distinct, GroupBy or Join. StringComparer.OrdinalIgnoreCase is the most-used built-in example.
9. How do records implement equality?
The compiler generates Equals(T), Equals(object), GetHashCode, == and !=. Equality compares the runtime type first via a hidden EqualityContract property, then every instance field using that field’s own equality, which means a record containing a List<T> compares that list by reference, not by contents.
10. Should you override equality on an EF Core entity?
Generally no. Entities have identity, not equivalence: two objects with the same contents can be different rows, and the change tracker identifies entities by primary key. If you do implement it, base it only on the immutable primary key, and never on mutable business fields, or the tracker and any hash-based caching will behave unpredictably.