C# HashSet Remove: Syntax, Return Value, and Performance
c# hashset remove: Learn how HashSet<T>.Remove works in C#: its return value, RemoveWhere for conditional deletion, performance characteristics, and common pitfalls.
c# hashset remove requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
In C#, HashSet<T>.Remove deletes an element by value and reports whether the element was actually present. It is the standard way to remove a single item when you know the value but not its position, and it is the method most developers reach for when implementing deduplication, tracking processed IDs, or maintaining an in-memory set of active items.
Remove Syntax and Return Value
The method takes a single argument of type T and returns a bool:
var activeIds = new HashSet<int> { 101, 202, 303 }; bool removed = activeIds.Remove(202); Console.WriteLine(removed); // True Console.WriteLine(activeIds.Count); // 2
The return value is the key detail: true means the element was found and removed, false means it was not in the set. The set itself never throws when the value is missing, so you can call Remove unconditionally without wrapping it in a try block.
This behavior differs from Dictionary<TKey, TValue>, where the indexer throws a KeyNotFoundException for missing keys. With HashSet<T>.Remove, the boolean result is the signal you use to decide whether subsequent logic should run.
What Happens When the Element Is Missing
Calling Remove on a value that does not exist leaves the set unchanged and returns false:
var tags = new HashSet<string> { "api", "database" }; bool removedMissing = tags.Remove("cache"); Console.WriteLine(removedMissing); // False Console.WriteLine(tags.Count); // 2
This makes Remove safe to call repeatedly. A common pattern is to use the return value to coordinate work between threads or to decide whether a resource should be released:
if (pendingJobs.Remove(jobId)) { // This thread won the race; only it should process the job. }
Because the operation is atomic only under external synchronization (see the thread-safety section below), this pattern requires a lock or a concurrent collection when multiple threads touch the same set.
Removing Multiple Elements with RemoveWhere
When you need to delete every element that satisfies a condition, RemoveWhere is the appropriate method. It accepts a Predicate<T> and removes all matching elements in a single pass:
var scores = new HashSet<int> { 10, 25, 40, 55, 70 }; int removedCount = scores.RemoveWhere(score => score > 50); Console.WriteLine(removedCount); // 2 Console.WriteLine(string.Join(", ", scores)); // 10, 25, 40
RemoveWhere returns the number of elements removed, which is useful for logging or for deciding whether a follow-up operation is necessary. It mutates the set in place, so it is more efficient than iterating and calling Remove for each match, especially when many elements qualify.
Performance Characteristics
HashSet<T>.Remove has an average time complexity of O(1) because the set stores elements in buckets derived from the hash code of each value. The runtime computes the hash, locates the bucket, and removes the element without scanning the entire collection.
This is a meaningful difference from List<T>.Remove, which performs a linear scan and has O(n) complexity:
| Collection | Remove by value | Complexity |
|---|---|---|
| HashSet<T> | O(1) average | Hash lookup |
| List<T> | O(n) | Linear scan |
The practical consequence: if you remove elements frequently from a collection that can grow large, a HashSet<T> avoids the repeated linear scans that a List<T> would incur. For very small collections (a handful of items), the difference is negligible, and a list may be simpler if order matters, since HashSet<T> does not preserve insertion order.
The O(1) claim depends on a well-distributed hash function. For custom types, a poor GetHashCode implementation can degrade removal to near O(n) because many elements land in the same bucket. If you store custom objects, make sure GetHashCode distributes values evenly.
Thread Safety and Concurrent Removal
HashSet<T> is not thread-safe. If one thread calls Remove while another thread enumerates the set or calls Add, the behavior is undefined and can throw InvalidOperationException or corrupt internal state. The foreach enumeration in particular fails if the set is modified during iteration.
For concurrent scenarios, use a ConcurrentDictionary<T, byte> or ConcurrentDictionary<T, T> as a set, or guard all access with a lock:
private readonly object _sync = new object(); private readonly HashSet<string> _active = new HashSet<string>(); public bool TryDeactivate(string id) { lock (_sync) { return _active.Remove(id); } }
A lock is sufficient when the set is small or when contention is low. When the set is large and accessed frequently from many threads, a concurrent collection avoids the lock overhead but changes the API surface, since ConcurrentDictionary uses TryRemove instead of Remove.
Common Mistakes and Edge Cases
One frequent mistake is calling Remove inside a foreach loop. The enumerator invalidates when the set changes, so the loop throws. Use RemoveWhere or collect the values to remove first, then remove them after the loop.
Another edge case is removing null from a HashSet<string>. null is a valid element for reference types, and Remove(null) works correctly as long as the comparer allows it, which the default comparer does.
For custom types, the default equality comparer uses GetHashCode and Equals. If you override Equals but not GetHashCode, removal may fail because the hash code changes between insertion and removal, or because two equal objects produce different hashes. Always override both methods together when you store custom objects in a HashSet<T>.
Remove does not shrink the internal bucket array. After removing many elements, the set keeps its allocated capacity. If memory usage matters and the set is permanently reduced, you can call TrimExcess to release unused slots, though this is rarely necessary for typical workloads.
Choosing Between Remove, RemoveWhere, and Clear
The three removal operations serve different purposes:
Remove(value)deletes one specific element and reports whether it existed.RemoveWhere(predicate)deletes all elements matching a condition and returns the count.Clear()deletes every element and resets the set to empty.
Use Remove when you have a concrete value and need to know whether it was present. Use RemoveWhere when the removal condition is a rule rather than a value. Use Clear when the set is no longer needed and the cost of rebuilding it later is acceptable. Clear does not release the internal capacity either; it resets the count to zero but keeps the bucket array allocated.