← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Simple Explanation

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.

The Technical Definition

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.

HashSet = Dictionary, With Only Keys — No Values

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 in

Why Does It Exist?

The Problem

Suppose 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.

The Solution

HashSet<string> uniqueEmails = []; foreach (string email in allSignups) uniqueEmails.Add(email); // Add silently ignores duplicates — no need to check first

Because 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.

Big Picture

LIST vs HASHSET — DEDUPLICATING SIGNUPS
List<string>
Allows duplicates
Contains scans every item
Ordered
HashSet<string>
Duplicates auto-rejected
Contains hashes straight to the bucket
Unordered
A HashSet trades "remembering order" for "guaranteed uniqueness at high speed."

How It Works

USING A HASHSET, STEP BY STEP
1. CREATE AND ADD
HashSet<string> tags = ["c#", "dotnet", "beginner"];
bool wasAdded = tags.Add("dotnet");   // false — already present
2. CHECK MEMBERSHIP
if (tags.Contains("dotnet"))
    Console.WriteLine("Already tagged.");
3. REMOVE AND ITERATE
tags.Remove("beginner");

foreach (string tag in tags)
    Console.WriteLine(tag);   // order is not guaranteed
4. SET OPERATIONS (WHAT MAKES IT A TRUE "SET")
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

Simple Example

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:

Real-World Example

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}"); // 2

Passing 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.

Analogy

A Guest List at the Door

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.

Under the Hood

HASHSET IS BUILT ON THE SAME HASHING AS DICTIONARY
1. SAME BUCKETS, JUST NO VALUES
2. WHY ADD CAN REJECT A DUPLICATE SO FAST
3. NO GUARANTEED ORDER, BY DESIGN

Common Confusion

1. HashSet vs List with manual duplicate checks

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.

2. HashSet vs Dictionary — when values don't matter

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.

Common Mistakes

Mistake 1 — Trying to index into a HashSet

WrongHashSet<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.

Mistake 2 — Assuming iteration order matches insertion order

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.

Mistake 3 — Checking Contains before every Add

UnnecessaryAdd 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 added

When Should I Use It?

Use HashSet<T> when

Reach for something else when

Mental Model

HashSet<T> = a Dictionary with only keys, no values
Add() = "let it in only if it's genuinely new" (returns a bool telling you which)
Contains() = hash-powered membership check, not a scan

Remember:
· Duplicates are impossible by construction — no manual "did I already add this?" checks needed.
· No indexer, no guaranteed order.
· Use it whenever the question is "is this here?", not "what's in position 3?"

Key Takeaway


Check Your Understanding

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"?

Show answer

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?

Show answer

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?

Show answer

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>?

Show answer

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.