Back to Blog
C#

C# HashSet Equality Comparer: Custom Equality

c# hashset equality comparer: Learn how to control HashSet<T> equality checks with IEqualityComparer<T> in C#. See practical examples for case-insensitive and custom o...

C#HashSetIEqualityComparerCustom ComparerSet Equality
A visual representation of a HashSet with a custom equality comparer, showing two distinct objects being treated as equal.

c# hashset equality comparer requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

When you create a HashSet<T> in C#, it relies on an equality comparer to decide whether two elements are duplicates. By default, it uses EqualityComparer<T>.Default, which for reference types means reference equality unless the type overrides Equals and GetHashCode. For value types, it uses the default value-based comparison. That works for many cases, but sometimes you need different semantics: case-insensitive string comparison, comparing objects by a subset of properties, or ignoring fields that shouldn't affect set membership. The c# hashset equality comparer mechanism lets you supply your own IEqualityComparer<T> to control exactly how the set determines equality.

How HashSet<T> Uses Equality by Default

A HashSet<T> stores elements in buckets based on the hash code returned by the comparer. When you add an element, it computes the hash code, finds the bucket, and then checks whether any existing element in that bucket is considered equal according to the comparer's Equals method. If an equal element already exists, the new element is not added.

The default comparer for a reference type like string is EqualityComparer<string>.Default, which uses ordinal comparison. That means "apple" and "Apple" are considered different. For a custom class, unless you override Equals and GetHashCode, the default is reference equality, so two distinct instances with identical property values are treated as different.

Creating a Custom IEqualityComparer<T>

To change the equality behavior, you implement IEqualityComparer<T>. The interface has two methods: Equals(T x, T y) and GetHashCode(T obj). Both must be consistent: if two objects are equal according to Equals, they must produce the same hash code. If they are not equal, they may still produce the same hash code, but that reduces performance because more collisions occur.

Here is a minimal custom comparer for a Person class that compares by Id only:

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) { return obj?.Id.GetHashCode() ?? 0; } }

The GetHashCode implementation must return the same value for two objects that are equal. Here, it returns the hash code of the Id property. If Person had other properties that were ignored in Equals, they are not part of the hash code.

Applying the Comparer When Constructing a HashSet

You pass the comparer to the HashSet<T> constructor. For example:

var people = new HashSet<Person>(new PersonIdComparer()) { new Person { Id = 1, Name = "Alice" }, new Person { Id = 1, Name = "Alicia" } };

Even though the two Person objects have different names, they share the same Id, so the second one is not added. The set contains only one element. This is useful when you want to deduplicate objects based on a key, without modifying the class itself.

Case-Insensitive String Sets

A common requirement is a set of strings that ignores case. Instead of writing a custom comparer from scratch, you can use the built-in StringComparer types. For example:

var keywords = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "C#", "csharp", "CSharp" };

All three strings are considered equal because the comparer ignores case. The set will contain only one element. StringComparer provides several options: OrdinalIgnoreCase, CurrentCultureIgnoreCase, InvariantCultureIgnoreCase, and others. The choice depends on whether you need culture-sensitive comparison.

Comparing Objects by Selected Properties

When you need to compare objects by multiple properties, you can build a comparer that combines hash codes. For example, to compare Order objects by CustomerId and OrderDate, you could write:

public class OrderComparer : IEqualityComparer<Order> { public bool Equals(Order x, Order y) { if (ReferenceEquals(x, y)) return true; if (x is null || y is null) return false; return x.CustomerId == y.CustomerId && x.OrderDate == y.OrderDate; } public int GetHashCode(Order obj) { unchecked { int hash = 17; hash = hash * 23 + obj.CustomerId.GetHashCode(); hash = hash * 23 + obj.OrderDate.GetHashCode(); return hash; } } }

The unchecked block prevents overflow exceptions during hash computation. The multiplication by a prime number reduces collisions. The GetHashCode method must be deterministic for the same object state.

Performance and Consistency of GetHashCode

The hash code is the backbone of the HashSet<T> lookup. If GetHashCode returns a constant for all objects, the set degrades to a linear list. If it returns different values for equal objects, the set will fail to deduplicate correctly. Therefore, the comparer must be consistent with the object's state at the time it is added.

A subtle issue arises when an object's properties change after it has been added to the set. If the hash code depends on mutable fields, and you modify those fields, the object may become "lost" in the set: it is in the wrong bucket, and operations like Contains or Remove may fail. The same applies to Dictionary<TKey, TValue> keys. If you need to modify an object that is stored in a set, you should remove it first, modify it, and then re-add it. Alternatively, use immutable objects for set members.

When a Custom Comparer Is Not Enough

A custom comparer controls equality and hashing, but it does not change the type itself. If you frequently need the same comparison logic across many collections, consider implementing IEquatable<T> on the type and overriding Equals and GetHashCode directly. That makes the default comparer use your logic. However, that approach is global: you cannot have two different equality semantics for the same type in different collections. A custom comparer gives you per-collection control, which is often more flexible.

For example, you might have a HashSet<Person> that compares by Id in one part of the application, and another that compares by Email in a different part. With a custom comparer, you can create two different sets with different comparers. If you relied on the type's overridden Equals, you would be stuck with one definition.

c# hashset equality comparer: Practical Usage and Code Examp | RYUSLOG DEV