The `HashSet<T>` API in practice: uniqueness, set algebra, comparers, and the sorted and frozen alternatives.
HashSet<T> is an unordered collection of unique values backed by a hash table, so Add, Remove and Contains are O(1) on average, versus O(n) for List<T>.Contains. Add returns false if the value is already there, which makes de-duplication simple. It has in-place set operations: UnionWith, IntersectWith, ExceptWith, SymmetricExceptWith, plus tests such as SetEquals, IsSubsetOf and Overlaps. Pass a comparer (for example StringComparer.OrdinalIgnoreCase) to define what "equal" means. Use SortedSet<T> when you need sorted order or range queries (O(log n)), and FrozenSet<T> (.NET 8+) for a set built once and read many times.
HashSet<T> is an unordered collection of unique elements backed by a hash table, so Add, Remove and Contains are average O(1). It's the right tool whenever the question is "have I seen this?" or "what do these sets have in common?". I cover Add returning bool, the set operations, comparers, why it beats List.Contains, and when SortedSet<T> or FrozenSet<T> fit better.
Add returns boolvar seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var email in emails)
{
if (!seen.Add(email)) // false = already present
logger.LogWarning("Duplicate email {Email}", Mask(email));
}
Add returns true if the item was added, false if it was already present — one lookup for "check and insert". Writing if (!set.Contains(x)) set.Add(x); does two.Remove also returns bool. TryGetValue(equalValue, out actual) returns the stored instance (useful for de-duplicating string instances or canonicalising objects).All of these modify the set in place (except the Is.../Overlaps/SetEquals queries):
| Method | Result | Math |
|---|---|---|
UnionWith(other) |
Add all elements of other |
A ∪ B |
IntersectWith(other) |
Keep only elements also in other |
A ∩ B |
ExceptWith(other) |
Remove elements in other |
A − B |
SymmetricExceptWith(other) |
Keep elements in exactly one | A △ B |
IsSubsetOf, IsSupersetOf, IsProperSubsetOf |
bool | ⊆, ⊇, ⊂ |
Overlaps(other) |
bool: any common element | A ∩ B ≠ ∅ |
SetEquals(other) |
bool: same elements, ignoring order and duplicates | A = B |
var required = new HashSet<string>(["orders:read", "orders:write"], StringComparer.Ordinal);
bool allowed = required.IsSubsetOf(user.Permissions);
var removedSkus = new HashSet<string>(oldCatalogue);
removedSkus.ExceptWith(newCatalogue); // SKUs that disappeared
These are optimised when other is a HashSet<T> with the same comparer (no rehashing). LINQ equivalents (Union, Intersect, Except) return new lazy sequences and build internal sets each time.
Pass an IEqualityComparer<T> at construction:
StringComparer.Ordinal or OrdinalIgnoreCase — never culture-sensitive comparers for identifiers.IEquatable<T>/GetHashCode (or use records), or supply a comparer that compares by a key (e.g. product SKU).HashSet.Contains beats List.ContainsList<T>.Contains is a linear scan: O(n), comparing every element (vectorised for primitives, but still proportional to size).HashSet<T>.Contains computes a hash, jumps to a bucket and compares a few entries: O(1) average.In a loop, that is the difference between O(n × m) and O(n + m). With 50,000 IDs checked against 50,000 IDs: ~2.5 billion comparisons vs ~100,000 operations. For very small collections (a handful of items), a list or array scan can be just as fast because hashing has a fixed cost — measure if it's on a hot path.
SortedSet<T> and FrozenSet<T>| Type | Implementation | Lookup | Ordered? | Mutable? | Use for |
|---|---|---|---|---|---|
HashSet<T> |
Hash table | O(1) avg | No | Yes | General membership, de-duplication |
SortedSet<T> |
Red-black tree | O(log n) | Yes, by comparer | Yes | Ordered unique items, Min/Max, GetViewBetween range queries |
FrozenSet<T> (.NET 8) |
Specialised hash strategies chosen at creation | O(1), faster than HashSet |
No | No | Read-only lookups built once (allow-lists, config, reserved words) |
ImmutableHashSet<T> |
Tree of buckets | O(log n) | No | Persistent (returns new sets) | Sharing snapshots with occasional updates |
private static readonly FrozenSet<string> AllowedExtensions =
new[] { ".pdf", ".png", ".jpg" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
FrozenSet is ideal for static allow-lists checked on every request: creation is slower, but lookups are optimised and it's safe to share across threads.
A nightly sync compared 200,000 customer IDs from a CRM with 200,000 from the billing database using crmIds.Where(id => !billingIds.Contains(id)) where billingIds was a List<string>. It took over 40 minutes. Converting billingIds to a HashSet<string>(StringComparer.Ordinal) and using ExceptWith made it run in under a second. The fix was one line; the lesson was to look for List.Contains inside loops during code review.
Checks that the candidate reaches for a set when uniqueness or membership matters, and knows the API.
Contains, uses Add's return value, names in-place set operations, mentions comparers, SortedSet and FrozenSet.List.Contains in loops, assumes ordering, or thinks HashSet is thread-safe.HashSet<T> uses the same design as Dictionary<TKey,TValue>, just without values:
private int[] _buckets; // 1-based index into _entries
private Entry[] _entries; // { int HashCode; int Next; T Value; }
private int _count, _freeList, _freeCount, _version;
private IEqualityComparer<T>? _comparer;
Next indexes inside the entries array.So Add/Contains/Remove are O(1) average, O(n) worst case with pathological hashing.
UnionWith(other): Add each element of other — O(m).ExceptWith(other): Remove each element of other — O(m). If other is the same set, clears.IntersectWith(other): if other is a HashSet<T> with an equal comparer, iterate this set and remove elements not in other (O(n)); otherwise, build a bit array marking elements found in other, then remove unmarked ones.SetEquals(other): with a compatible HashSet, compare counts then check containment; otherwise, may need to build a temporary set.IsSubsetOf with compatible sets short-circuits when Count > other.Count.The "same comparer" fast paths are why it's important to construct sets consistently.
SortedSet<T> internalsSortedSet<T> is a red-black tree (a self-balancing binary search tree):
Min/Max are O(log n) (walk left/right).GetViewBetween(lower, upper) returns a live view over a range, e.g. all timestamps within a window.FrozenSet<T> internalsToFrozenSet() analyses the elements and picks an implementation:
Construction can cost much more than building a HashSet, so FrozenSet pays off only when the set is long-lived and looked up frequently. It's immutable and therefore thread-safe.
A HashSet<int> stores, per element, a 12-byte entry (hash, next, value) plus 4 bytes of bucket — roughly 4× the memory of an int[]. A SortedSet<int> uses a ~40-byte node object per element. For tens of millions of integers, consider sorted arrays with BinarySearch, or BitArray for dense ranges.
a.Intersect(b) builds a HashSet from b internally each time you enumerate, and yields distinct elements from a found in it. It doesn't modify anything and is lazy. Use LINQ for one-off expressions on sequences; use HashSet<T> methods when you already have sets or need repeated operations.
List.Contains vs HashSet.Contains vs FrozenSet.Contains for sizes 5, 100 and 100,000.SortedSet<Order> with a comparer that compares only CreatedAt and add two orders with the same timestamp.GetViewBetween to query a range of dates from a SortedSet<DateTime>.IntersectWith performance with a HashSet argument vs a List argument.using System.Collections.Frozen;
// De-duplicate and detect duplicates
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var email in incomingEmails)
{
if (!seen.Add(email))
Console.WriteLine($"Duplicate: {email}");
}
// Set algebra (in place)
var required = new HashSet<string> { "read", "write", "admin" };
var granted = new HashSet<string> { "read", "write" };
var missing = new HashSet<string>(required);
missing.ExceptWith(granted); // { "admin" }
bool exact = granted.SetEquals(["write", "read", "read"]); // true
// Get the stored instance back
seen.TryGetValue("[email protected]", out string? original);
// Sorted set with range query
var prices = new SortedSet<decimal> { 9.5m, 20m, 14m, 31m };
var midRange = prices.GetViewBetween(10m, 25m); // 14, 20
// Built once, read many times
FrozenSet<string> blockedCountries = new[] { "XX", "YY" }.ToFrozenSet();
bool blocked = blockedCountries.Contains(countryCode);
Contains then AddWhat happens: if (!set.Contains(x)) set.Add(x);.
Why it is wrong: two lookups where one suffices.
Do instead: use the bool returned by Add.
List.Contains inside loopsWhat happens: membership checks against large lists in nested loops or LINQ Where.
Why it is wrong: O(n × m) time; becomes minutes for large data.
Do instead: build a HashSet<T> once and check against it.
What happens: new HashSet<string>() for emails or codes that should be case-insensitive.
Why it is wrong: "[email protected]" and "[email protected]" are treated as different; duplicates slip through.
Do instead: pass StringComparer.OrdinalIgnoreCase (or the correct comparer) at construction.
HashSet<T>What happens: output generated by enumerating a HashSet.
Why it is wrong: order is undefined and can change after removals or between runs.
Do instead: sort explicitly or use SortedSet<T> when order matters.
SortedSet comparer that ignores identityWhat happens: sorting orders by date in a SortedSet<Order> with a date-only comparer.
Why it is wrong: the set treats equal-comparing items as duplicates and drops them silently.
Do instead: add tie-breakers (e.g. ID) to the comparer, or use a list and sort.
What happens: changing a property used by GetHashCode after adding the object.
Why it is wrong: the element can no longer be found or removed.
Do instead: use immutable elements or compare by an immutable key.
FrozenSet frequentlyWhat happens: ToFrozenSet() per request.
Why it is wrong: construction is relatively expensive; the benefit needs many lookups.
Do instead: build frozen sets once (static or singleton) for long-lived, read-only data.
HashSet across threadsWhat happens: a static HashSet<string> updated by multiple requests.
Why it is wrong: not thread-safe; corrupts internal state.
Do instead: use locks, ConcurrentDictionary<T, byte> as a set, or immutable/frozen sets.
Distinct() repeatedly instead of maintaining a setWhat happens: calling .Distinct().ToList() every time an item is added.
Why it is wrong: rebuilds a set each time — O(n) per addition.
Do instead: maintain a HashSet<T> and add incrementally.
What happens: caseInsensitiveSet.IntersectWith(caseSensitiveSet).
Why it is wrong: falls back to slower paths and the semantics depend on which set's comparer is used.
Do instead: keep comparers consistent across sets you combine.
A strong answer connects the set type to the access pattern and the lifetime of the data.
| Pattern | Best type | Reason |
|---|---|---|
| Many lookups, data changes | HashSet<T> |
O(1) add and lookup |
| Static allow/deny list | FrozenSet<T> |
Fastest reads, immutable |
| Need min/max or ranges | SortedSet<T> |
Ordered tree |
| Shared snapshot across threads | ImmutableHashSet<T> |
Safe to share, cheap copies |
| Concurrent adds | ConcurrentDictionary<T, byte> |
No built-in concurrent set |
Seniors also copy the comparer when cloning a set (new HashSet<T>(set, set.Comparer)), because losing a case-insensitive comparer silently changes behaviour.
A set stores unique items and answers "is X in it?" quickly.
HashSet<T>: hash table without values. Add, Remove, Contains are O(1) average. No ordering.SortedSet<T>: red-black tree. O(log n) operations, items kept sorted, supports Min, Max and GetViewBetween ranges.FrozenSet<T> (.NET 8+): built once, optimised for fast reads; immutable.ImmutableHashSet<T>: immutable with cheap "modified copies", for snapshot-style sharing.Set algebra: UnionWith, IntersectWith, ExceptWith, SymmetricExceptWith, IsSubsetOf, Overlaps, SetEquals. The biggest everyday win is replacing List.Contains (O(n)) in loops with HashSet.Contains (O(1)).
System.Object gives every type Equals, GetHashCode, ToString, GetType, the protected MemberwiseClone and Finalize, plus static ReferenceEquals. The review problem is the Equals/GetHashCode contract: equal objects must return equal hash codes. If Equals compares Email but GetHashCode is still identity-based, two "equal" customers land in different hash buckets, so Dictionary, HashSet, Distinct and GroupBy treat them as different. I'd implement IEquatable<Customer>, override both methods using the same immutable fields, compare emails case-insensitively in both, overload ==/!=, and seal the class. For value-like data a record generates all of this.
Last updated 30 Oct 2026.