C# Equality Comparer: Implementing IEqualityComparer<T>
c# equality comparer: Learn how to implement a C# equality comparer with IEqualityComparer<T> for LINQ and Dictionary, including GetHashCode rules and performance trad...
When you pass a custom class to Distinct() or use it as a dictionary key, the runtime has to decide when two instances are equal. By default, a reference type uses reference equality unless Equals is overridden. That often does not match the identity you care about, such as an Id property. A C# equality comparer lets you supply that logic explicitly through IEqualityComparer<T>.
Why Default Equality Is Not Always Enough
For a class like Person, two instances with the same Id are logically the same person, but object.Equals compares references. Consider:
public class Person { public int Id { get; set; } public string Name { get; set; } } var p1 = new Person { Id = 1, Name = "Alice" }; var p2 = new Person { Id = 1, Name = "Alice" }; Console.WriteLine(p1.Equals(p2)); // False
Distinct() over a list of Person objects will return both instances. If you want to deduplicate by Id, you need a custom equality comparison. The same problem appears when Person is used as a dictionary key: two keys that represent the same entity will be treated as different entries.
Implementing IEqualityComparer<T>
IEqualityComparer<T> has two methods: Equals(T x, T y) and GetHashCode(T obj). The implementation must be consistent: if Equals returns true, both objects must return the same hash code.
public class PersonIdComparer : IEqualityComparer<Person> { public bool Equals(Person x, Person y) { if (ReferenceEquals(x, y)) return true; if (x is null || y is null) return false; return x.Id == y.Id; } public int GetHashCode(Person obj) { if (obj is null) return 0; return obj.Id.GetHashCode(); } }
The ReferenceEquals check avoids the property access when both references are identical. The null checks prevent NullReferenceException when either argument is null. GetHashCode returns a hash based on the same field used by Equals, so equal objects always produce the same hash.
The GetHashCode Contract
GetHashCode is not just a formality. Collections like Dictionary<TKey, TValue> and HashSet<T> use it to place objects into buckets. When you look up a key, the runtime computes its hash code, finds the bucket, and then calls Equals only on objects in that bucket. If two equal objects return different hash codes, the lookup fails even though Equals would return true.
The rules are:
- If
Equals(x, y)istrue,GetHashCode(x)must equalGetHashCode(y). - The hash code of an object must not change while the object is stored in a hash-based collection.
- A good hash code distributes values across the integer range to reduce collisions.
Using only Id.GetHashCode() is appropriate when Id is an int. If the identity is composite, combine the hash codes of each field. A common pattern is:
public int GetHashCode(Person obj) { unchecked { int hash = 17; hash = hash * 23 + obj.Id.GetHashCode(); hash = hash * 23 + (obj.Name?.GetHashCode() ?? 0); return hash; } }
This is not the only valid pattern, but it avoids the common mistake of returning a constant, which would force every object into the same bucket and turn lookups into linear scans.
Using the Comparer with LINQ
LINQ methods that compare elements accept an IEqualityComparer<T>. Distinct, Except, Intersect, and Union all have overloads with a comparer parameter.
var people = new List<Person> { new Person { Id = 1, Name = "Alice" }, new Person { Id = 1, Name = "Alice" }, new Person { Id = 2, Name = "Bob" } }; var distinct = people.Distinct(new PersonIdComparer()).ToList();
Now distinct contains two Person objects: one with Id = 1 and one with Id = 2. The comparer is also useful when you need to find differences between two collections by business key:
var current = GetCurrentPeople(); var previous = GetPreviousPeople(); var added = current.Except(previous, new PersonIdComparer()).ToList();
The same comparer can be passed to GroupBy, Contains, and other LINQ operators that perform equality checks.
Using the Comparer with Dictionary and HashSet
Dictionary<TKey, TValue> and HashSet<T> have constructors that accept an IEqualityComparer<T>. This is often the cleanest way to make a custom type behave as a key without changing its own Equals implementation.
var lookup = new Dictionary<Person, string>(new PersonIdComparer()); lookup.Add(new Person { Id = 1, Name = "Alice" }, "Engineer"); var key = new Person { Id = 1, Name = "Alice" }; Console.WriteLine(lookup[key]); // "Engineer"
The dictionary uses the comparer for both hashing and equality. The Person class does not need to override Equals or GetHashCode. This keeps the domain object free of collection-specific concerns and lets you define different identities for different contexts, such as comparing by Id in one place and by Email in another.
Performance and Allocation Considerations
The comparer is called on every lookup, insertion, and removal. The cost of GetHashCode is therefore on the hot path. A comparer that computes a hash from multiple string properties will be slower than one that uses a single integer field. If the same objects are used repeatedly as keys, you can cache the hash code in the object itself, but only if the object is immutable. A mutable object whose hash changes after insertion breaks the dictionary contract.
Avoid allocating in Equals and GetHashCode. For example, calling string.ToLower() inside GetHashCode creates a new string on every call. Use StringComparer.OrdinalIgnoreCase for case-insensitive string comparisons instead of writing your own normalization logic. The built-in comparer is optimized and handles nulls correctly.
Common Pitfalls and Alternatives
A frequent mistake is implementing Equals and GetHashCode on the class itself and then changing a field that is part of the hash code. If the object is already in a HashSet, the set can no longer find it. Using a separate comparer does not solve this; the key object must still be stable while in the collection.
Another issue is ignoring nulls. Equals receives null for either side when the collection contains null or when you compare with null. GetHashCode must handle a null argument. Returning 0 for null is a common convention, but the important part is consistency.
For string keys, prefer StringComparer.OrdinalIgnoreCase or StringComparer.Ordinal over a custom comparer. For numeric or simple value types, EqualityComparer<T>.Default is usually sufficient. Comparer<T>.Default is for ordering, not equality, and should not be used where an equality comparer is expected.
When to Override Equals Instead
A custom comparer is the right tool when you need different equality rules for different operations or when you cannot modify the type. If the type is under your control and has one natural identity, overriding Equals and implementing IEquatable<T> may be simpler. The default equality comparer will then use those overrides, and LINQ and dictionary operations will work without passing a comparer. However, that approach bakes the identity into the type, so you cannot easily switch between Id-based and Name-based equality in the same codebase. A comparer keeps that decision at the call site.