Updated on

A HashSet<T> in C# is a collection that holds each value at most once and finds any of them in constant time on average. Adding a value that is already in the set changes nothing and tells us so.

To download the source code for this article, you can visit our GitHub repository.

That trade is the whole design. The set spends memory on a hash table so that Contains(), Add() and Remove() cost the same on nine elements as on nine million, and in exchange it keeps no insertion order and offers no indexer.

Everything below follows from those two sentences: how to build one, how to check membership, how to combine two sets with the union and intersection methods, and which type to reach for when the set needs to be read-only.

What Is a HashSet in C#?

A HashSet<T> is a collection that stores unique elements and answers “is this value in here?” in constant time on average. It lives in System.Collections.Generic and has shipped with .NET since version 3.5.

Uniqueness is the whole point. Adding a value that is already present returns false and leaves the set unchanged, so we never write a duplicate check ourselves.

Speed comes from hashing. The set computes each element’s hash code, uses it to pick a bucket, and compares only the handful of items in that bucket. Add(), Remove() and Contains() are all O(1) on average, against O(n) for the same lookup on a List<T>.

Two things it gives up. It keeps no insertion order, so iteration order is not something to rely on, and it has no indexer, so set[0] does not compile.

It also implements ISet<T>, which is where union, intersection and difference come from.

That bucket array is a hash table, and the HashSet<T> declaration names every interface the type implements:

public class HashSet<T> : System.Collections.Generic.ICollection<T>,
System.Collections.Generic.IEnumerable<T>,
System.Collections.IEnumerable,
System.Collections.Generic.IReadOnlyCollection<T>,
System.Collections.Generic.IReadOnlySet<T>,
System.Collections.Generic.ISet<T>,
System.Runtime.Serialization.IDeserializationCallback,
System.Runtime.Serialization.ISerializable

How to Create a HashSet in C#

For most of our examples, we intend to use a HashSet that contains strings of some of the popular programming languages used today.

Let’s start by creating an empty HashSet:

var languages = new HashSet<string>();

When the values already sit in a list, LINQ’s ToHashSet() builds a set from it, and a constructor overload takes a sequence together with the comparer that decides what counts as a duplicate:

var fromList = languageList.ToHashSet();
var caseInsensitive = new HashSet<string>(languageList, StringComparer.OrdinalIgnoreCase);

The first line drops any repeated values in languageList. The second goes further and treats “C#” and “c#” as the same value, because the comparer, not the type, decides equality.

Add Items to a HashSet

Let’s begin by adding a set of programming languages into our HashSet<string> object by taking advantage of the Add() method:

public HashSet<string> ProgrammingLanguages()
{
    var languages = new HashSet<string>();

    languages.Add("C");
    languages.Add("C++");
    languages.Add("C#");
    languages.Add("Java");
    languages.Add("Scala");
    languages.Add("TypeScript");
    languages.Add("Python");
    languages.Add("JavaScript");
    languages.Add("Rust");

    return languages;
}

Here, we insert elements into the languages HashSet by invoking the Add() method.

In some cases, we may want to initialize HashSets directly with values:

var languages = new HashSet<string> { "C", "C++", "C#", "Java" };

For all our tests, we are going to reuse the same class object instance to make our examples as easy to follow as possible:

private readonly HashSetsInCSharpMethods _methods = new HashSetsInCSharpMethods();

Let’s also initialize our HashSet in the test constructor to avoid repetitive code:

private readonly HashSet<string> _languages;

public HashSetInCSharpUnitTests()
{
    _languages = _methods.ProgrammingLanguages();
}

Next, we can verify that our HashSet has nine elements and check whether it contains one of the elements (“C#” ):

Assert.IsInstanceOfType(_languages, typeof(HashSet<string>));
Assert.AreEqual(9, _languages.Count);
Assert.IsTrue(_languages.Contains("C#"));

We can successfully prove that _languages is of type HashSet<string>. We read the Count property to check the number of elements in our HashSet.

Also, we use the inbuilt Contains() method to check if a HashSet has a specific element. The method takes the element as a parameter and returns a boolean value indicating whether or not the element is present in the set.

This technique can be useful for quickly checking if an element exists in the set without having to iterate through all of the elements.

Duplicate Elements in a HashSet

HashSets do not allow for duplicate elements. It will not affect the set if we try to add a duplicate element. It uses a hashing structure that ensures that each element can only appear once in the set.

Let’s attempt to add duplicate elements to the _languages HashSet:

_languages.Add("C");
_languages.Add("C++");
_languages.Add("C#");

Assert.IsInstanceOfType(_languages, typeof(HashSet<string>));
Assert.AreEqual(9, _languages.Count);

When we try to add duplicate values, our _languages HashSet does not get modified, because it already contains those values. Therefore, its count remains nine instead of twelve.

How Do We Check if a HashSet Contains an Element?

Contains() is the direct answer. It takes the value, hashes it, and returns a bool without walking the set, so the check costs the same on nine elements and on nine million.

Add() answers the same question as a side effect. It returns false when the value is already there, so a single call both tests and inserts.

TryGetValue() goes one step further. It reports whether the value is present and hands back the instance the set is actually holding, which is different from the one we passed in whenever a custom IEqualityComparer<T> decides equality on part of the object.

Whether two values count as the same is decided by GetHashCode() and Equals(). Reference types that do not override both are compared by reference, so two class instances with identical contents can both land in the set unless the type says otherwise.

That is the lever we control: overriding Equals() and GetHashCode() on our own type is what makes the set agree with us about which two objects are the same value.

Let’s put this theory into practice:

Assert.IsTrue(_languages.TryGetValue("C#", out _));
Assert.IsTrue(_languages.Contains("C#"));
Assert.IsFalse(_languages.TryGetValue("Assembly", out _));
Assert.IsFalse(_languages.Contains("Assembly"));

Here, _languages does not contain “Assembly” but contains “C#” and uses the discard operator to ignore the TryGetValue() method’s return value.

Remove Elements From a HashSet

To remove an item from the HashSet, we can use the Remove() method. Like the Add() method, it takes the object as a parameter and removes it from the HashSet:

public HashSet<string> RemoveElement(HashSet<string> hashSet, string value)
{
    hashSet.Remove(value);

    return hashSet;
}

Here, the RemoveElement() method takes a HashSet<string> and a string as parameters and uses the Remove() method to remove an element before returning the updated HashSet.

Next, we can verify that the RemoveElement() method successfully removes elements:

var elementToRemove = "Java";

var updatedLanguages = _methods.RemoveElement(_languages, elementToRemove);

Assert.IsFalse(updatedLanguages.Contains(elementToRemove));
Assert.AreEqual(8, _languages.Count);

We invoke the RemoveElement() method and pass our HashSet, and a string (“Java”), which we can prove is removed as the updated HashSet does not contain that value, and its count decreases by one.

RemoveWhere() Method

Besides using the inbuilt Remove() method, we can use the RemoveWhere() method that takes a predicate as a parameter to set conditions that determine whether we remove an element. To illustrate this concept, let’s implement a HashSet that stores unique random numbers:

public HashSet<int> RandomInts(int size, int seed = 42)
{
    var rand = new Random(seed);
    var numbers = new HashSet<int>();

    for (int i = 0; i < size; i++)
    {
        numbers.Add(rand.Next());
    }

    return numbers;
}

Our RandomInts() method takes the number of integers to generate as its input. It uses the inbuilt random class to generate random numbers and inserts each value into a HashSet before returning it. The seed has a default so the test that uses this method draws the same hundred numbers on every run.

Next, let’s implement a method that returns true when an integer is odd. We are going to use this method as our predicate when we eventually implement the RemoveWhereElement() method:

public bool IsOdd(int num)
{
    return num % 2 != 0;
}

The comparison is against zero rather than one on purpose. In C#, -3 % 2 is -1, so a check written as num % 2 == 1 reports every negative odd number as even.

Finally, let’s implement our RemoveWhereElement() method by passing the predicate function IsOdd() as a parameter:

public HashSet<int> RemoveWhereElement(HashSet<int> hashSet)
{
    hashSet.RemoveWhere(IsOdd);

    return hashSet;
}

We can verify that the RemoveWhereElement() method removes all the odd numbers in the HashSet:

var numbers = _methods.RandomInts(100);
var oddNumbers = new HashSet<int>();

foreach (var item in numbers)
{
    if (_methods.IsOdd(item) == true)
    {
        oddNumbers.Add(item);
    }
}

_methods.RemoveWhereElement(numbers);
var testValue = oddNumbers.First();
var checkValue = _methods.IsOdd(testValue);

Assert.IsTrue(checkValue);
Assert.IsFalse(oddNumbers.IsSubsetOf(numbers));
Assert.AreEqual(100, numbers.Union(oddNumbers).Count());

Here, we create a HashSet to store odd numbers from the random numbers we generate. Next, we invoke the RemoveWhereElement() method to remove all odd numbers from numbers (contains all the numbers, including odd numbers). Then, we check whether the first element in the oddNumbers HashSet is an odd number. Finally, we assert that oddNumbers is not a subset of numbers, and verify that their union still adds up to 100 elements.

Remove Elements From a HashSet Through the Clear() Method

What if we want to remove all the elements in a HashSet? We can make use of the Clear() inbuilt method.

Let’s verify that the Clear() method removes all elements from the _languages HashSet:

_languages.Clear();

Assert.AreEqual(0, _languages.Count);
Assert.IsNull(_languages.FirstOrDefault());

After invoking the Clear() method, we can prove that we remove all the elements from the _languages HashSet as it has a count of zero.

Iterate Through a HashSet in C#

To iterate through a HashSet, we can use the statements available in C# such as for, foreach and while loops to achieve our goals:

public List<int> CreateList(HashSet<int> hashSet)
{
    var list = new List<int>();

    foreach (var item in hashSet)
    {
        list.Add(item);
    }

    return list;
}

Here, the CreateList() method takes a HashSet<int> object as its sole parameter and adds all the elements to the list.

Alternatively, we can simply call the inbuilt ToList() method to convert the HashSet into a list:

var list = hashSet.ToList();

Let’s verify that CreateList() successfully returns a populated List<int> object:

var numbers = _methods.RandomInts(100);

var numbersList = _methods.CreateList(numbers);

CollectionAssert.AllItemsAreInstancesOfType(numbersList, typeof(int));
Assert.AreEqual(numbers.Count, numbersList.Count);

We must remember that a HashSet does not store elements in a specific order, so the order in which we iterate through the elements varies.

HashSet Set Operations Methods in C#

Let’s understand some methods we can use for set operations as we work with HashSets.

IsProperSubsetOf/IsProperSuperSetOf

When we want to check whether a HashSet instance is a proper subset of another HashSet instance, we use the IsProperSubsetOf() method. Likewise, we can use the IsProperSupersetOf() method to determine if a HashSet is a superset of another HashSet:

var moreLanguages = new HashSet<string> {"C", "C++", "C#", "Java", "Scala", "TypeScript",
                    "Python", "JavaScript", "Rust", "Assembly", "Pascal"};

Assert.IsTrue(_languages.IsSubsetOf(moreLanguages));
Assert.IsTrue(_languages.IsProperSubsetOf(moreLanguages));
Assert.IsTrue(moreLanguages.IsSupersetOf(_languages));
Assert.IsTrue(moreLanguages.IsProperSupersetOf(_languages));

We create a larger set moreLanguages that contains more elements, including all the elements in the languages set. Therefore, languages is a proper subset of moreLanguages , and the latter is the proper superset of the former.

UnionWith

When we want to join two sets, we perform a union operation. For example, when we want to perform a union between two sets, A and B, we copy the elements in set B over into set A.

Let’s perform a UnionWith() operation between _languages and moreLanguages HashSet to illustrate this concept:

var moreLanguages = new HashSet<string> { "Assembly", "Pascal", "HTML", "CSS", "PHP" };

_languages.UnionWith(moreLanguages);
Assert.AreEqual(14, _languages.Count);

The UnionWith() method copies the elements in moreLanguages HashSet into the _languages HashSet hence, the latter now has fourteen elements instead of nine.

IntersectWith

An intersection between sets A and B entails finding the common elements. To accomplish such an operation in C#, we use the inbuilt IntersectWith() method.

Let’s understand how to perform an intersection operation with an example:

var moreLanguages = new HashSet<string> { "C", "C++", "C#", "Java", "Scala", "Assembly",
                    "Pascal", "HTML", "CSS", "PHP" };

_languages.IntersectWith(moreLanguages);

Assert.AreEqual(5, _languages.Count);
Assert.IsTrue(_languages.Contains("C"));
Assert.IsTrue(_languages.Contains("C++"));
Assert.IsTrue(_languages.Contains("C#"));
Assert.IsTrue(_languages.Contains("Java"));
Assert.IsTrue(_languages.Contains("Scala"));
Assert.IsFalse(_languages.Contains("Assembly"));

Here, the IntersectWith() method selects the elements that are common in both _languages and moreLanguages sets.

ExceptWith

This operation performs a set difference operation between two sets. If we perform a set difference between sets A and B, the operation returns the elements in A that are not present in B.

Let’s understand this concept with another example:

var moreLanguages = new HashSet<string> { "C", "C++", "C#", "Java", "Scala", "Assembly",
                    "Pascal", "HTML", "CSS", "PHP" };

_languages.ExceptWith(moreLanguages);

Assert.AreEqual(4, _languages.Count);
Assert.IsTrue(_languages.Contains("TypeScript"));
Assert.IsTrue(_languages.Contains("Python"));
Assert.IsTrue(_languages.Contains("JavaScript"));
Assert.IsTrue(_languages.Contains("Rust"));
Assert.IsFalse(_languages.Contains("Assembly"));

The ExceptWith() method returns the elements that are in _languages but not in moreLanguages, which are: “TypeScript”, “Python”, “JavaScript” and “Rust”.

SymmetricExceptWith

Sometimes, we may want to modify a HashSet to store unique elements between two sets. That’s where the SymmetricExceptWith() method comes into play, as we can use it to accomplish our purpose.

Let’s look at using the SymmetricExceptWith() method:

var moreLanguages = new HashSet<string> { "Assembly", "Pascal", "HTML", "CSS", "PHP" };

_languages.SymmetricExceptWith(moreLanguages);

Assert.AreEqual(14, _languages.Count);

The SymmetricExceptWith() method modifies the _languages HashSet to make it have unique elements from both itself and moreLanguages HashSet. Therefore, since both sets have unique values, the modified _languages HashSet now contains fourteen elements.

Which HashSet Methods Do We Use Most in C#?

HashSet<T> has a small surface, and four methods cover most day-to-day work: Add(), Contains(), Remove() and Clear().

Add() returns a bool rather than void. It is true when the value was new and false when the set already held it, which is the cheapest duplicate test in the language.

Contains() is the method the type exists for. It hashes the value and looks in one bucket, so its cost does not grow with the size of the set.

Count is a property, not a method. Calling the LINQ Count() extension on a set works, but the property is the direct read.

The set-operation methods (UnionWith(), IntersectWith(), ExceptWith(), SymmetricExceptWith()) all mutate the set they are called on and return nothing. The predicate ones (IsSubsetOf(), IsSupersetOf(), Overlaps(), SetEquals()) only ask a question and return a bool.

TryGetValue() is the odd one out: it hands back the instance already stored in the set, which matters when the equality comparer treats two different objects as equal.

MemberWhat it doesReturns
Add(T item)Adds the item if the set does not already hold itbool, false if it was already there
Contains(T item)Membership testbool
Remove(T item)Removes one itembool, false if it was not there
RemoveWhere(Predicate<T> match)Removes every item matching the predicateint, how many were removed
Clear()Empties the setvoid
TryGetValue(T equalValue, out T actualValue)Finds the instance the set is actually storingbool
CountHow many items the set holds (a property, not a method)int
UnionWith(IEnumerable<T> other)Adds everything from other into this setvoid (mutates)
IntersectWith(IEnumerable<T> other)Keeps only what both havevoid (mutates)
ExceptWith(IEnumerable<T> other)Removes everything other hasvoid (mutates)
SymmetricExceptWith(IEnumerable<T> other)Keeps only what exactly one of them hasvoid (mutates)
IsSubsetOf(IEnumerable<T> other)Is every item of ours in other?bool
IsProperSubsetOf(IEnumerable<T> other)Subset, and other has at least one morebool
IsSupersetOf(IEnumerable<T> other)Is every item of other in ours?bool
IsProperSupersetOf(IEnumerable<T> other)Superset, and we have at least one morebool
Overlaps(IEnumerable<T> other)Do they share at least one item?bool
SetEquals(IEnumerable<T> other)Same items, order ignoredbool
EnsureCapacity(int capacity)Grows the internal table up frontint, the capacity now available
TrimExcess()Shrinks the internal table to the current countvoid
ToHashSet() (LINQ, on any IEnumerable<T>)Builds a new set from a sequenceHashSet<T>

Benefits of a HashSet in C#

First, since HashSets use hash tables, they facilitate quick insertion and retrieval operations, because lookups run in constant time on average: the set compares only the entries in one bucket rather than scanning the whole collection.

Also, we can use HashSets in applications that do not allow duplicate elements, which helps us eliminate data redundancy, the same job we would otherwise do by removing duplicates from a list.

Finally, HashSets can be useful for quickly checking if an element is present in the set without having to iterate through all of the elements.

Drawbacks of a HashSet in C#

One drawback of using a HashSet is that it does not maintain the order of its elements, so the order in which we iterate over them may vary. When order does matter, a SortedSet keeps its elements in order, and it is worth reading how a HashSet compares with a SortedSet before picking one.

Microsoft states both halves of that trade in one line. The HashSet<T> API reference says: “A HashSet<T> collection is not sorted and cannot contain duplicate elements.” The uniqueness we came for and the ordering we gave up are the same hash table seen from two sides.

A HashSet also costs more memory per element than a list holding the same values, because it stores a hash code and a chain index alongside every entry, much as a dictionary does for its keys.

Finally, a HashSet is not thread-safe for concurrent writes. A set shared between threads needs either external locking or an immutable set.

How Do We Make a HashSet Read-Only in C#?

There is no AsReadOnly() on HashSet<T> the way there is on List<T>, so C# gives us four other routes.

Wrap it in ReadOnlySet<T>, added in .NET 9. The wrapper implements IReadOnlySet<T> over an existing set and throws on every mutating call.

Expose the set as IReadOnlySet<T> instead. The interface has been in the framework since .NET 5 and is unchanged on .NET 10, the version we tested here. Callers get Contains() and the subset checks and no Add() or Remove(), but the underlying set stays mutable through the original reference, so this is encapsulation rather than a guarantee.

Reach for ImmutableHashSet<T> when the guarantee has to be real. Every Add() returns a new set and leaves the original untouched, which makes it safe to share across threads.

Use FrozenSet<T> from .NET 8 when the contents are fixed at startup and read constantly afterwards. ToFrozenSet() builds it, building costs more than building a HashSet<T>, and every lookup afterwards is faster.

TypeNamespaceCan the contents change?Reach for it when
HashSet<T>System.Collections.GenericYesThe set is built and used in one place
IReadOnlySet<T>System.Collections.GenericNot through this reference; yes through the originalWe want callers to read but not write
ReadOnlySet<T>System.Collections.ObjectModelNo, through the wrapperWe hand a set out and want mutating calls to fail
ImmutableHashSet<T>System.Collections.ImmutableNo, every change returns a new setThe set is shared across threads
FrozenSet<T>System.Collections.FrozenNoThe contents are fixed at startup and read constantly

The last two rows lead outside this article: ImmutableHashSet<T> and the rest of the immutable collections all share the same copy-on-write design, so learning one of them teaches the rest.

Conclusion

A HashSet<T> is the right collection whenever the answer we need is “have I seen this value before”, and the wrong one whenever order or position matters.

However, it is important to consider our specific needs before deciding on a solution as it may not be suitable for all situations. Additionally, we have to keep in mind that a HashSet does not maintain the order of its elements and does not allow for accessing elements by their indices.

Tested with .NET 10.