C# HashSet Usage: Efficient Set Operations
Learn how to use C# HashSet for fast membership checks, deduplication, and set operations, with practical examples and performance tradeoffs.
When working with collections in C#, HashSet<T> is often overlooked in favor of List<T> or Dictionary<TKey, TValue>. Yet for many scenarios—especially those involving membership checks, deduplication, and set algebra—HashSet<T> is the most direct and efficient choice. This article covers practical C# HashSet usage patterns, from basic operations to performance tradeoffs, so you can decide when it fits your code.
What Is a HashSet in C#?
HashSet<T> is a collection that stores unique elements. It uses a hash-based equality comparer to determine uniqueness, which gives it an average-case constant-time complexity for Add, Contains, and Remove. Unlike List<T>, a HashSet does not preserve insertion order. If you need ordering, copy the elements to a List<T> and sort them, or choose another collection type.
The type is part of System.Collections.Generic. It implements ICollection<T>, so it works with LINQ and other standard collection APIs, but it is optimized for set-like operations rather than index-based access.
Creating and Populating a HashSet
The simplest way to create a HashSet<T> is with the parameterless constructor. You can then add elements individually or use a collection initializer.
var numbers = new HashSet<int> { 1, 2, 3, 4 }; numbers.Add(5); numbers.Add(3); // duplicate, ignored Console.WriteLine(numbers.Count); // 5
The Add method returns a bool indicating whether the element was actually added. This is useful when you need to know if a duplicate was encountered.
bool added = numbers.Add(5); // false, already present
If you have an existing collection, you can populate a HashSet using UnionWith, which adds all elements from another collection while preserving uniqueness.
var moreNumbers = new List<int> { 4, 5, 6, 7 }; numbers.UnionWith(moreNumbers); // numbers now contains 1,2,3,4,5,6,7
Checking for Membership
The most common reason to use a HashSet is fast membership testing. The Contains method runs in O(1) average time, regardless of the number of elements.
if (numbers.Contains(4)) { Console.WriteLine("4 is in the set"); }
This is significantly faster than List<T>.Contains, which performs a linear scan. For a collection with thousands of items, the difference is measurable and becomes more pronounced as the collection grows.
Removing Elements
Removing an element is just as straightforward. The Remove method returns true if the element was found and removed.
bool removed = numbers.Remove(3);
You can also remove elements that match a predicate using RemoveWhere.
numbers.RemoveWhere(n => n % 2 == 0); // removes all even numbers
Clear removes all elements, and TrimExcess can reduce memory usage after a large number of removals, similar to List<T>.TrimExcess.
Set Operations: Union, Intersect, Except
HashSet<T> provides methods that implement set algebra directly. These are the operations you would otherwise write manually with loops and LINQ.
Union
UnionWith modifies the current set to contain all elements from itself and another collection.
var unionA = new HashSet<int> { 1, 2, 3 }; var unionB = new HashSet<int> { 3, 4, 5 }; unionA.UnionWith(unionB); // unionA: {1,2,3,4,5}
Intersect
IntersectWith keeps only the elements that appear in both sets.
var intersectA = new HashSet<int> { 1, 2, 3 }; var intersectB = new HashSet<int> { 3, 4, 5 }; intersectA.IntersectWith(intersectB); // intersectA: {3}
Except
ExceptWith removes from the current set any elements that are present in the other collection.
var exceptA = new HashSet<int> { 1, 2, 3 }; var exceptB = new HashSet<int> { 3, 4, 5 }; exceptA.ExceptWith(exceptB); // exceptA: {1,2}
Symmetric Except
SymmetricExceptWith keeps elements that are in one set or the other, but not both. This is the set equivalent of XOR.
var symmetricA = new HashSet<int> { 1, 2, 3 }; var symmetricB = new HashSet<int> { 3, 4, 5 }; symmetricA.SymmetricExceptWith(symmetricB); // symmetricA: {1,2,4,5}
These methods modify the original set. If you need a new set without altering the originals, use LINQ methods like Union, Intersect, and Except, which return IEnumerable<T>. However, the in-place methods are more memory-efficient because they avoid creating intermediate collections.
Performance Characteristics and Memory Tradeoffs
The primary advantage of HashSet<T> is its average O(1) time complexity for Add, Contains, and Remove. This is achieved through hashing: each element is assigned a hash code, and the element is stored in a bucket based on that hash. When the hash function distributes elements evenly, operations are constant-time. In the worst case, if many elements collide, performance degrades toward O(n), but the built-in hash functions for common types like int, string, and Guid are well-distributed.
Memory usage is higher than List<T> because a HashSet uses a hash table internally, with extra bookkeeping for buckets and hash codes. For large collections, the overhead can be significant. Use TrimExcess after a bulk removal to release unused buckets.
Another consideration is that HashSet<T> does not preserve insertion order. If your algorithm depends on the order in which elements were added, you must either use a List or explicitly sort the set's contents after each modification.
When to Use HashSet vs List vs Dictionary
The choice between these collection types depends on what you need to do.
| Collection | Best for | Lookup | Ordering | Memory |
|---|---|---|---|---|
List<T> | Indexed access, ordered sequences | O(n) linear | Preserved | Low |
HashSet<T> | Uniqueness, membership tests, set ops | O(1) average | Not preserved | Moderate |
Dictionary<TKey,TValue> | Key-value pairs, lookup by key | O(1) average | Not preserved | Higher |
Use a HashSet when you need to enforce uniqueness and perform frequent membership checks. For example, validating that a list of IDs contains no duplicates, or tracking which items have already been processed.
Use a List when you need to access elements by index, preserve order, or when the collection is small and linear scans are acceptable.
Use a Dictionary when each element has an associated value. Conceptually, a HashSet<T> is a collection of keys without associated values, so if you find yourself storing null values in a dictionary just to use its keys, a HashSet is the cleaner choice.
Common Pitfalls and Edge Cases
One subtle issue is that HashSet<T> uses the default equality comparer unless you provide a custom one. For reference types that do not override Equals and GetHashCode, this means reference equality by default. If you want value-based equality, you must either implement IEquatable<T> on your type or pass a custom IEqualityComparer<T> to the constructor.
var people = new HashSet<Person>(); // reference equality unless Person overrides Equals/GetHashCode
If Person overrides Equals and GetHashCode, the set will use those. Otherwise, two separate instances with identical data are considered different.
Another edge case is null handling. HashSet<T> allows null for reference types, but only one null can be present because of the uniqueness constraint. Adding null twice has no effect.
Because HashSet<T> does not guarantee order, code should not assume a specific iteration sequence. If you need a stable order, copy the elements to a List and sort it, or use SortedSet<T> when you need sorted order at the cost of O(log n) operations.
Finally, be careful when modifying a HashSet while iterating over it. The foreach loop will throw an InvalidOperationException if the collection is modified. Use RemoveWhere or collect items to remove in a separate list, then remove them after the loop.