A HashSet stores unique values only — the same hashing speed as a Dictionary, but the value itself is the whole point.
A newsletter has collected 200,000 email signups over the years, from a dozen different signup forms across the site. Naturally, plenty of people signed up more than once. Before sending the next campaign, you need one thing: a list where every email address appears exactly once. You could scan through a List<string> and manually check "have I already added this one?" for every single entry — but you already know from the last lesson exactly how slow that gets.
In this lesson, you'll learn about HashSet<T> — a collection built for exactly one job: guaranteeing every value it holds is unique, at the same near-instant speed as a dictionary lookup.
A HashSet<T> is a collection that only holds unique values — try to add the same value twice, and the second attempt is simply ignored. There's no indexing by position (no set[0]), and it makes no promise about order. All it promises is: every value in here is different from every other value in here.
HashSet<T> is a generic collection representing a mathematical set — an unordered collection with no duplicate elements. Internally, it uses the same hashing technique you saw powering Dictionary<TKey, TValue> in the previous lesson, which is why membership checks (Contains) and additions are both close to instant, regardless of how many elements the set holds.
The cleanest way to understand a HashSet<T> is to notice it's really just a Dictionary<T, T> where the value doesn't matter — only "is this key present?" does. Same hash buckets, same near-instant lookup, just without the associated data.
HashSet<string> subscribers = [];
subscribers.Add("amy@example.com"); // true — added
subscribers.Add("amy@example.com"); // false — already present, ignored
subscribers.Add("ben@example.com"); // true — added
Console.WriteLine(subscribers.Count); // 2, not 3 — duplicates never got inSuppose you try to deduplicate that 200,000-entry subscriber list with a plain List<string>:
List<string> uniqueEmails = [];
foreach (string email in allSignups) // 200,000 raw signups, with duplicates
{
if (!uniqueEmails.Contains(email)) // Contains on a List scans the whole thing
uniqueEmails.Add(email);
}List<T>.Contains does a linear scan, just like the search you saw in the dictionaries lesson. As uniqueEmails grows, each new Contains check gets slower — checking 200,000 signups against a list that's steadily growing toward 150,000 unique entries becomes painfully slow, potentially billions of comparisons in total.
HashSet<string> uniqueEmails = [];
foreach (string email in allSignups)
uniqueEmails.Add(email); // Add silently ignores duplicates — no need to check firstBecause HashSet<T> uses hashing internally, both Add and Contains run in average constant time — the whole 200,000-item deduplication finishes in a fraction of the time the list-based version would take.
Contains scans every itemContains hashes straight to the bucketHashSet<string> tags = ["c#", "dotnet", "beginner"];
bool wasAdded = tags.Add("dotnet"); // false — already present
List<T>.Add (which returns void), HashSet<T>.Add returns a bool telling you whether the value was actually new.if (tags.Contains("dotnet"))
Console.WriteLine("Already tagged.");
Contains on a HashSet<T> is hash-powered and fast — a world apart from List<T>.Contains, which scans.tags.Remove("beginner");
foreach (string tag in tags)
Console.WriteLine(tag); // order is not guaranteed
HashSet<string> teamA = ["amy", "ben", "cara"];
HashSet<string> teamB = ["ben", "dev"];
teamA.IntersectWith(teamB); // teamA becomes {"ben"} — common members
// UnionWith and ExceptWith are also available, for combining or subtracting sets
HashSet<T> exists as its own type rather than just being "a dictionary with no values."List<string> rawSignups =
[
"amy@example.com", "ben@example.com", "amy@example.com",
"cara@example.com", "ben@example.com", "amy@example.com",
];
HashSet<string> uniqueEmails = [];
foreach (string email in rawSignups)
uniqueEmails.Add(email);
Console.WriteLine($"Raw signups: {rawSignups.Count}"); // 6
Console.WriteLine($"Unique emails: {uniqueEmails.Count}"); // 3
foreach (string email in uniqueEmails)
Console.WriteLine(email); // amy@example.com, ben@example.com, cara@example.com (order not guaranteed)Code → Meaning → Result:
Add was silently rejected.if (!uniqueEmails.Contains(...)) check was needed — Add already handles the "is this new?" logic internally.A marketing platform pulls email signups from several different forms — the homepage, a blog popup, and a checkout page — and needs one clean, deduplicated mailing list before sending a campaign.
public class SubscriberList
{
private readonly HashSet<string> _emails = new(StringComparer.OrdinalIgnoreCase);
public bool Subscribe(string email)
{
string normalized = email.Trim();
return _emails.Add(normalized); // true = newly subscribed, false = already on the list
}
public bool IsSubscribed(string email) => _emails.Contains(email.Trim());
public int TotalSubscribers => _emails.Count;
}
var list = new SubscriberList();
Console.WriteLine(list.Subscribe("Amy@Example.com")); // true — new
Console.WriteLine(list.Subscribe("ben@example.com")); // true — new
Console.WriteLine(list.Subscribe("amy@example.com")); // false — same as "Amy@Example.com", case-insensitively
Console.WriteLine(list.Subscribe(" ben@example.com ")); // false — same as "ben@example.com" after trimming
Console.WriteLine($"Total subscribers: {list.TotalSubscribers}"); // 2Passing StringComparer.OrdinalIgnoreCase to the constructor changes how the set decides two strings are "equal" — here, so that "Amy@Example.com" and "amy@example.com" are correctly treated as the same subscriber. You'll see more about customizing equality comparisons like this later in the course.
A bouncer at an exclusive event has one job: let each guest in exactly once. When someone arrives, the bouncer checks the guest list — instantly, not by re-reading the whole list from the top — and either checks them in (if this is their first time tonight) or turns them away (if they already came through). The bouncer never cares when in the evening each guest arrived, only whether they're already on the list.
A HashSet<T> is that bouncer: Add is "let them in if they're new," Contains is "check the list," and neither operation cares about order — only about uniqueness.
Dictionary, a HashSet<T> maintains an internal array of buckets, and each value's hash code determines which bucket it belongs in.Add, the set hashes the value, jumps to that bucket, and checks whether an equal value is already sitting there.Add returns false. If not, the value is inserted and Add returns true — one bucket lookup, not a full scan.HashSet<T> makes no promises about iteration order — the internal bucket order has nothing to do with insertion order.You could enforce uniqueness in a List<T> yourself by always calling Contains before Add — but that Contains call is a slow linear scan, defeating the purpose. HashSet<T> gives you the same guarantee with hash-powered speed baked in, so use it whenever uniqueness is the actual requirement.
If you find yourself writing Dictionary<string, bool> just to track "have I seen this key?", that's a sign you actually want a HashSet<string> instead — it says exactly what you mean, with no unused value slot.
Wrong — HashSet<T> has no indexer:
HashSet<string> tags = ["a", "b", "c"];
string first = tags[0]; // compiler error — no such thing as tags[0] If you need positional access, you don't actually want a set — use List<T>, or convert with LINQ's ToList() (covered later in this course) once you're done deduplicating.
Relying on a HashSet<T> to remember "I added these in this order" — it never promised that. If order matters, keep a separate List<T>, or check whether you actually needed a set at all.
Contains before every Add Unnecessary — Add already tells you whether the value was new:
if (!tags.Contains(newTag)) // redundant extra lookup
tags.Add(newTag);Simpler and just as correct — one call does both jobs:
bool wasNew = tags.Add(newTag); // Add already returns whether it was addedList<T>.Dictionary<TKey, TValue>.Stack<T> or Queue<T>, next lesson.Dictionary.Add and Contains are both fast, average-case constant-time operations, unlike List<T>.Contains's linear scan.Add returns a bool — no separate Contains check needed before adding.HashSet<T> answers "is this present?", not "what's at position N?"You've seen how HashSet<T> guarantees uniqueness with dictionary-speed lookups. Let's check your understanding.
1. What happens when you call set.Add("x") on a HashSet<string> that already contains "x"?
Correct: C
Why C is correct: A HashSet<T> silently rejects duplicate values — the set is unchanged, and Add returns false to let you know the value was already present.
Why A is incorrect: Adding a duplicate is a completely normal, expected operation — it never throws.
Why B is incorrect: This is the exact behavior a HashSet<T> is designed to prevent — duplicates simply cannot exist in it.
Why D is incorrect: Adding an existing value has no effect on what's already there — it doesn't trigger a removal.
Reinforcement: Add's bool return value is your signal for whether something was genuinely new.
2. Why is hashSet.Contains(value) typically much faster than list.Contains(value) on a large collection?
Correct: B
Why B is correct: Just like Dictionary, a HashSet<T> hashes the value being checked and jumps straight to its bucket, checking only the (usually short) chain of entries there — no need to inspect every other value in the set.
Why A is incorrect: A HashSet<T> is not sorted; its internal order is determined by hash codes, not by value comparisons.
Why C is incorrect: A HashSet<T> scales to very large collections while staying fast — that's the entire point of hashing.
Why D is incorrect: List<T>.Contains works correctly — it's simply doing a linear scan, which is inherently slower for large collections than a hash-based lookup.
Reinforcement: The speed advantage comes from the same hashing mechanism you saw powering Dictionary lookups.
3. A team wants to track which of their 10,000 registered users have logged in at least once today, checking this thousands of times per minute as login events stream in. Which collection best fits?
Correct: B
Why B is correct: The requirement is purely "has this user ID been seen today?" — a membership question with no associated data and no ordering need, which is exactly what HashSet<T> is built for, at high speed.
Why A is incorrect: Checking membership in a list before every add means an ever-slower linear scan as the day goes on — a poor fit for a high-frequency check.
Why C is incorrect: Parsing IDs out of one giant concatenated string to check membership would be far slower and more error-prone than any proper collection.
Why D is incorrect: A fixed array has no natural way to check "is this ID present" without scanning, and doesn't map cleanly onto user IDs as keys.
Reinforcement: High-frequency "have I seen this before?" checks are the textbook use case for HashSet<T>.
4. What is the key difference between HashSet<T> and Dictionary<TKey, TValue>?
Correct: B
Why B is correct: Both use the same hashing mechanism internally, but a dictionary pairs each key with a value, while a set only cares whether a value is present at all — there's no separate value to associate with it.
Why A is incorrect: HashSet<T> uses the exact same hashing approach as Dictionary, which is precisely why both are fast.
Why C is incorrect: It's the opposite — HashSet<T> specifically disallows duplicate values, just as Dictionary disallows duplicate keys.
Why D is incorrect: They're closely related but serve different purposes — one stores key/value pairs, the other stores unique values only.
Reinforcement: Think of HashSet<T> as "the uniqueness half of Dictionary," without the value-storage half.
You've now covered the three hash-powered collections that make lookups fast — arrays and lists for order, dictionaries for key lookup, sets for uniqueness. Next: two collections defined entirely by the order you take things out — Stack<T> and Queue<T>.
dotnetmadeeasy.com — Learn C# and .NET, the right way.