C# HashSet Usage: Efficient Set Operations
c# hashset usage: Learn how to use HashSet in C# for fast membership checks, set operations, and deduplication, with practical examples and performance considerations.
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, you must sort or convert it to a list explicitly.
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 setA = new HashSet<int> { 1, 2, 3 }; var setB = new HashSet<int> { 3, 4, 5 }; setA.UnionWith(setB); // setA: {1,2,3,4,5}
Intersect
IntersectWith keeps only the elements that appear in both sets.
setA.IntersectWith(setB); // if setA was {1,2,3} and setB {3,4,5}, result is {3}
Except
ExceptWith removes from the current set any elements that are present in the other collection.
setA.ExceptWith(setB); // removes elements of setB from setA
Symmetric Except
SymmetricExceptWith keeps elements that are in one set or the other, but not both. This is the set equivalent of XOR.
setA.SymmetricExceptWith(setB); // result: elements unique to either set
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 maintains an internal array of buckets plus the elements themselves. Each element also stores a reference to its hash code or uses the default comparer's hash. 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. A HashSet is essentially a Dictionary with only keys and no 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, 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>(); // uses reference equality
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, any code that assumes a specific iteration sequence will break. If you need to iterate in 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.