Collections
Reviewed & published by Brayan K
By the end of this lesson you'll be able to store many values in one place and pick the right tool for the job — a growable List<T> for ordered items, a Dictionary<K,V> for instant lookups by name, a HashSet<T> for unique values, and Stack/Queue for ordered processing.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Understand why a List<T> beats a fixed-size array when the count can change
- Create, add to, search, sort and remove from a List<T>
- Store and look up key-value pairs with Dictionary<K,V> (safely, with TryGetValue)
- Keep only unique values and do set maths with HashSet<T>
- Use Stack<T> (LIFO) and Queue<T> (FIFO) for ordered processing
- Choose the right collection for any situation — and avoid the common crashes
💡 Real-World Analogy
Think of collections as different containers in your kitchen. A List is a numbered shelf — items sit in order and you can grab one by its position. A Dictionary is a labelled spice rack — you find "cumin" instantly by name, never by counting jars. A HashSet is a bowl of unique fridge magnets — adding a duplicate changes nothing. A Stack is a pile of plates — you always take from the top (last on, first off). A Queue is the checkout line — first person in is the first served. Picking the right container is half the job; the rest is just Add, Remove, and looking things up.
📊 Which Collection Should I Use?
| Collection | Order | Duplicates | Find an item by… | Best for |
|---|---|---|---|---|
| array (int[]) | ✅ Index | ✅ Yes | Position | Fixed, known size |
| List<T> | ✅ Index | ✅ Yes | Position | Growable ordered lists |
| Dictionary<K,V> | ❌ No | Unique keys | Key | Fast lookup by name/ID |
| HashSet<T> | ❌ No | ❌ No | Membership | Unique items, set maths |
| Stack<T> | LIFO | ✅ Yes | Top only | Undo, backtracking |
| Queue<T> | FIFO | ✅ Yes | Front only | Task queues, BFS |
Rule of thumb: reach for List<T> by default, switch to Dictionary<K,V> the moment you find yourself searching by a name or ID, and use HashSet<T> when duplicates would be a bug.
1. Arrays vs List<T>
An array (string[]) has a fixed length — you set its size once and it can never grow or shrink. That's perfect when the count is known and stable. But most of the time you don't know how many items you'll have, so you reach for a List. A List<T> is a resizable array: it starts empty and grows automatically every time you .Add(...). The <T> is the type it holds — List<string> holds strings, List<int> holds whole numbers. Read this worked example first, then you'll build one yourself.
using System;
using System.Collections.Generic; // <-- List, Dictionary, HashSet live here
class Program
{
static void Main()
{
// An ARRAY is fixed-size. You decide its length up front and it
// NEVER changes. Good when you know exactly how many items there are.
string[] weekdays = { "Mon", "Tue", "Wed", "Thu", "Fri" };
Console.WriteLine($"Array length: {weekdays.Length}"); // Array length: 5
Console.WriteLine($"Third day: {weekdays[2]}"); // Third day: Wed
// weekdays.Add("Sat"); // ❌ won't compile — arrays can't grow
// A List<T> is a RESIZABLE array. It starts empty and grows as you add.
// The <string> part says "this list holds strings".
List<string> tasks = new List<string>();
tasks.Add("Wash up"); // index 0
tasks.Add("Walk dog"); // index 1
tasks.Add("Email Sam"); // index 2
Console.WriteLine($"\nList count: {tasks.Count}"); // List count: 3
Console.WriteLine($"First task: {tasks[0]}"); // First task: Wash up
}
}
// ✅ Expected output:
// Array length: 5
// Third day: Wed
//
// List count: 3
// First task: Wash up2. Working with List<T>
A List<T> gives you a handful of methods you'll use constantly: Add appends an item, Remove deletes the first matching value, Contains answers a yes/no membership question, Count tells you how many items there are, Sort reorders them, and list[i] reads or writes the item at position i (remember, the first item is index 0). The cleanest way to visit every item is a foreach. Read the worked example, then finish the one below it.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// Create a List<string> and fill it one item at a time.
List<string> fruits = new List<string>();
fruits.Add("Apple");
fruits.Add("Banana");
fruits.Add("Cherry");
fruits.Add("Date");
// foreach walks every item, in order, no index needed.
Console.WriteLine("All fruits:");
foreach (string fruit in fruits)
Console.WriteLine($" 🍎 {fruit}");
// Remove by VALUE (not by position).
fruits.Remove("Banana");
Console.WriteLine($"\nAfter removing Banana: {fruits.Count} items"); // 3 items
// Access by INDEX — the first item is [0], not [1].
Console.WriteLine($"First fruit: {fruits[0]}"); // Apple
// Contains() is a quick yes/no membership check.
Console.WriteLine($"Contains Cherry? {fruits.Contains("Cherry")}"); // True
// Sort() reorders the list in place (alphabetically for strings).
fruits.Sort();
Console.WriteLine("\nSorted:");
foreach (string f in fruits)
Console.WriteLine($" {f}"); // Apple, Cherry, Date
}
}
// ✅ Expected output:
// All fruits:
// 🍎 Apple
// 🍎 Banana
// 🍎 Cherry
// 🍎 Date
//
// After removing Banana: 3 items
// First fruit: Apple
// Contains Cherry? True
//
// Sorted:
// Apple
// Cherry
// DateYour turn. The shopping-list program below is almost complete — fill in the three ___ blanks using the hints in the comments, then run it and check the output.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// 🎯 YOUR TURN — fill in each ___ then press "Try it Yourself".
// Goal: build a shopping list, add three items, and print them all.
// 1) Create an empty List that holds strings
List<string> shopping = ___; // 👉 new List<string>()
// 2) Add three items with .Add(...)
shopping.Add("Milk");
shopping.___("Bread"); // 👉 the method that appends one item: Add
shopping.Add("Eggs");
// 3) Print every item with a foreach loop
Console.WriteLine("Shopping list:");
foreach (string item in ___) // 👉 the list to walk through: shopping
Console.WriteLine($" - {item}");
Console.WriteLine($"Total items: {shopping.Count}");
// ✅ Expected output:
// Shopping list:
// - Milk
// - Bread
// - Eggs
// Total items: 3
}
}3. Dictionary<TKey, TValue>
A Dictionary stores key → value pairs, and looking a value up by its key is almost instant (on average O(1) — it doesn't slow down as the dictionary grows). Use one whenever you find data by a unique identifier: a username, a product code, a setting name. Add or overwrite with dict[key] = value, and read with dict[key] — but only if you're certain the key exists. The safe way to read is TryGetValue, which returns false instead of crashing when the key is missing.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// A Dictionary maps a KEY to a VALUE. Here: name (string) -> age (int).
// You can fill it inline with { key, value } pairs.
Dictionary<string, int> ages = new Dictionary<string, int>
{
{ "Alice", 30 },
{ "Bob", 25 },
{ "Charlie", 35 }
};
// Add a new entry with key indexing. dict[key] = value.
ages["Diana"] = 28;
// Look one up by its key — fast, even with millions of entries.
Console.WriteLine($"Alice is {ages["Alice"]} years old"); // Alice is 30...
// TryGetValue is the SAFE lookup: it returns false instead of crashing
// if the key is missing, and hands you the value via 'out'.
if (ages.TryGetValue("Eve", out int eveAge))
Console.WriteLine($"Eve is {eveAge}");
else
Console.WriteLine("Eve not found in dictionary"); // this runs
// Iterate every pair. Each item is a KeyValuePair with .Key and .Value.
Console.WriteLine("\nAll people:");
foreach (KeyValuePair<string, int> kvp in ages)
Console.WriteLine($" {kvp.Key}: {kvp.Value}");
// ContainsKey() is a quick membership check on the keys.
Console.WriteLine($"\nContains Bob? {ages.ContainsKey("Bob")}"); // True
Console.WriteLine($"Total entries: {ages.Count}"); // 4
}
}
// ✅ Expected output:
// Alice is 30 years old
// Eve not found in dictionary
//
// All people:
// Alice: 30
// Bob: 25
// Charlie: 35
// Diana: 28
//
// Contains Bob? True
// Total entries: 4Now you try. The stock-tracker below maps a product name to how many you have in stock. Fill in the three ___ blanks, then run it.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// 🎯 YOUR TURN — fill in each ___ then run it.
// Goal: store stock counts by product name, then look one up.
// 1) Create an empty Dictionary: string key -> int value
Dictionary<string, int> stock = ___; // 👉 new Dictionary<string, int>()
// 2) Add three products with key indexing (stock["name"] = number)
stock["Apples"] = 12;
stock["Bananas"] = 7;
stock["Cherries"] = ___; // 👉 any whole number, e.g. 40
// 3) Look up how many Bananas there are
int bananaCount = stock[___]; // 👉 the key in "double quotes": "Bananas"
Console.WriteLine($"Bananas in stock: {bananaCount}");
Console.WriteLine($"Product lines: {stock.Count}");
// ✅ Expected output:
// Bananas in stock: 7
// Product lines: 3
}
}4. HashSet<T> — Unique Values
A HashSet<T> holds only unique values — try to add a duplicate and it's quietly ignored, so the set never contains the same item twice. That makes it ideal for tags, permission lists, and stripping duplicates out of data. It also does proper set maths: UnionWith (everything in either set), IntersectWith (only what's in both), and ExceptWith (remove the overlap). Membership tests with Contains are very fast — much faster than searching a List for big collections.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// A HashSet holds UNIQUE elements only — duplicates are silently ignored.
HashSet<string> tags = new HashSet<string>();
tags.Add("csharp");
tags.Add("dotnet");
tags.Add("programming");
tags.Add("csharp"); // duplicate — not added a second time
Console.WriteLine($"Tags count: {tags.Count}"); // 3, not 4
Console.WriteLine("\nAll tags:");
foreach (string tag in tags)
Console.WriteLine($" #{tag}");
// HashSets also do real set maths.
HashSet<string> moreTags = new HashSet<string> { "dotnet", "web", "api" };
// Union — everything in EITHER set.
HashSet<string> all = new HashSet<string>(tags);
all.UnionWith(moreTags);
Console.WriteLine($"\nUnion: {string.Join(", ", all)}");
// Intersection — only what's in BOTH sets.
HashSet<string> common = new HashSet<string>(tags);
common.IntersectWith(moreTags);
Console.WriteLine($"Intersection: {string.Join(", ", common)}"); // dotnet
}
}
// ✅ Expected output:
// Tags count: 3
//
// All tags:
// #csharp
// #dotnet
// #programming
//
// Union: csharp, dotnet, programming, web, api
// Intersection: dotnet5. Stack<T> and Queue<T>
These two control the order in which you take things out. A Stack is LIFO — Last In, First Out — like a stack of plates or a browser's "back" history: Push adds to the top, Pop removes the top, Peek looks without removing. A Queue is FIFO — First In, First Out — like a checkout line or a print queue: Enqueue joins the back, Dequeue serves the front. Whenever order-of-processing matters, one of these two is exactly what you want.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// Stack = Last In, First Out (LIFO) — like a pile of plates.
Stack<string> plates = new Stack<string>();
plates.Push("Red plate");
plates.Push("Blue plate");
plates.Push("Green plate"); // <-- last one on top
Console.WriteLine("=== Stack (LIFO) ===");
Console.WriteLine($"Top plate: {plates.Peek()}"); // Peek = look, don't remove
Console.WriteLine($"Remove top: {plates.Pop()}"); // Pop = take the top off
Console.WriteLine($"New top: {plates.Peek()}"); // Blue plate
// Queue = First In, First Out (FIFO) — like a line at a shop.
Queue<string> customers = new Queue<string>();
customers.Enqueue("Alice"); // joins the back of the line
customers.Enqueue("Bob");
customers.Enqueue("Charlie");
Console.WriteLine("\n=== Queue (FIFO) ===");
Console.WriteLine($"Next in line: {customers.Peek()}"); // Alice
Console.WriteLine($"Serve: {customers.Dequeue()}"); // removes Alice
Console.WriteLine($"Now next: {customers.Peek()}"); // Bob
Console.WriteLine($"Remaining: {customers.Count}"); // 2
}
}
// ✅ Expected output:
// === Stack (LIFO) ===
// Top plate: Green plate
// Remove top: Green plate
// New top: Blue plate
//
// === Queue (FIFO) ===
// Next in line: Alice
// Serve: Alice
// Now next: Bob
// Remaining: 2🔎 Deep Dive: don't change a collection while you foreach it
This is the single most common collection bug. Adding to or removing from a list inside a foreach over that same list throws InvalidOperationException: Collection was modified. The loop relies on the collection staying still; you pulled the rug out from under it.
// ❌ Throws "Collection was modified" mid-loop
foreach (int n in numbers)
if (n < 0) numbers.Remove(n);
// ✅ Fix 1 — loop over a COPY, modify the original
foreach (int n in numbers.ToList())
if (n < 0) numbers.Remove(n);
// ✅ Fix 2 — one-liner that removes all matches
numbers.RemoveAll(n => n < 0);RemoveAll (with a small condition called a lambda) is usually the cleanest fix when you just want to drop everything that matches a rule.
Putting It Together: a Word Counter
Here's a small but genuinely useful program that combines this lesson's ideas — an array of words from Split, and a Dictionary<string,int> that counts how many times each word appears. The ContainsKey check is the heart of it: seen the word before? add one; first time? start at one. You understand every line now.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// === Word frequency counter — List + Dictionary working together ===
string sentence = "the cat sat on the mat the cat";
// Split the sentence into words on each space -> a string array.
string[] words = sentence.Split(' ');
// The dictionary maps each word to how many times we've seen it.
Dictionary<string, int> counts = new Dictionary<string, int>();
foreach (string word in words)
{
// If we've seen the word, add 1; otherwise start it at 1.
if (counts.ContainsKey(word))
counts[word] += 1; // bump the existing count
else
counts[word] = 1; // first time we've seen this word
}
// Print every word and its final count.
foreach (KeyValuePair<string, int> pair in counts)
Console.WriteLine($"{pair.Key}: {pair.Value}");
// Output:
// the: 3
// cat: 2
// sat: 1
// on: 1
// mat: 1
}
}
// ✅ Expected output:
// the: 3
// cat: 2
// sat: 1
// on: 1
// mat: 1This "count occurrences" pattern is everywhere — tallying votes, log levels, inventory, survey answers. Learn it once and you'll reuse it constantly.
Pro Tips
- 💡 Default to List<T>: use a plain array only when the size is fixed and you want the tiny performance edge.
- 💡 Always read dictionaries with TryGetValue: dict[key] throws if the key is missing; TryGetValue never crashes.
- 💡 Use HashSet<T> for fast membership: checking set.Contains(x) stays fast even with millions of items; List.Contains slows right down.
- 💡 Never add/remove inside a foreach over the same collection — loop a copy with .ToList() or use RemoveAll(...).
- 💡 Stack for "most recent", Queue for "fairest order": undo wants the last action first; a task queue wants the oldest task first.
Common Errors (and the fix)
- "System.Collections.Generic.KeyNotFoundException": you read dict["Eve"] but that key doesn't exist. Check first with ContainsKey("Eve"), or read safely with if (dict.TryGetValue("Eve", out int v)).
- "System.ArgumentException: An item with the same key has already been added": you used dict.Add(key, ...) for a key that's already there. Use dict[key] = value to overwrite instead, or guard with if (!dict.ContainsKey(key)).
- "System.IndexOutOfRangeException" / "ArgumentOutOfRangeException": you indexed past the end — e.g. list[3] on a list of 3 items (valid indexes are 0, 1, 2). Remember indexes start at 0 and the last one is list.Count - 1.
- "System.InvalidOperationException: Collection was modified": you added to or removed from a collection inside a foreach over it. Loop a copy (foreach (var x in list.ToList())) or use list.RemoveAll(...).
- "CS0246: The type or namespace name 'List<>' could not be found": you forgot the import. Add using System.Collections.Generic; at the top — it's where List, Dictionary, HashSet, Stack and Queue all live.
📋 Quick Reference
| Task | Code | Result |
|---|---|---|
| New list | new List<string>() | empty list |
| Add an item | list.Add("x") | appends "x" |
| Read by index | list[0] | first item |
| How many? | list.Count | item count |
| Contains? | list.Contains("x") | true / false |
| Add to dict | dict["k"] = 5 | sets/overwrites |
| Safe lookup | dict.TryGetValue("k", out v) | true / false |
| Key exists? | dict.ContainsKey("k") | true / false |
| Stack add / take | Push(x) / Pop() | LIFO |
| Queue add / take | Enqueue(x) / Dequeue() | FIFO |
Frequently Asked Questions
Q: When should I use an array instead of a List<T>?
Use an array only when the number of items is fixed and known up front (e.g. the 7 days of the week, or a chess board's 64 squares). For everything that grows, shrinks, or has an unknown size, use a List<T> — it does everything an array does plus Add/Remove.
Q: How is a Dictionary different from a List?
A List finds items by their numeric position (list[2]). A Dictionary finds them by a meaningful key (ages["Alice"]) and that lookup is near-instant regardless of size. If you ever loop a list just to find the item with a matching name or ID, a dictionary will be faster and clearer.
Q: Why did adding a duplicate to my HashSet do nothing?
That's the whole point of a set — it holds each value only once. Add returns false when the value is already present and the count doesn't change. If you need to keep duplicates, use a List<T> instead.
Q: What's the practical difference between a Stack and a Queue?
A Stack hands back the most recently added item (LIFO) — great for undo and backtracking. A Queue hands back the oldest item (FIFO) — great for processing things fairly in arrival order, like jobs or messages.
Mini-Challenge: Count the Words
No blanks this time — just a brief and an outline to keep you on track. Build a Dictionary<string,int> that counts how many times each word appears in a sentence, then print each word with its count. Run it and check your output against the expected lines in the comments.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// 🎯 MINI-CHALLENGE: Count how often each word appears
// 1. Start from this sentence:
// string sentence = "red blue red green blue red";
// 2. Split it into words with sentence.Split(' ').
// 3. Make a Dictionary<string, int> called "tally".
// 4. foreach over the words. If tally already ContainsKey(word),
// add 1 to tally[word]; otherwise set tally[word] = 1.
// 5. foreach over tally and print "{word}: {count}".
//
// ✅ Expected output (order may vary):
// red: 3
// blue: 2
// green: 1
// your code here
}
}🎉 Lesson Complete
- ✅ A List<T> is a growable array — Add, Remove, Contains, Count, Sort, and index with list[i]
- ✅ A Dictionary<K,V> maps keys to values with near-instant lookup — read safely using TryGetValue
- ✅ A HashSet<T> keeps only unique values and does set maths (UnionWith, IntersectWith)
- ✅ Stack<T> is LIFO (Push/Pop); Queue<T> is FIFO (Enqueue/Dequeue)
- ✅ Indexes start at 0; reading a missing key or past the end throws — guard with ContainsKey/TryGetValue
- ✅ Never add or remove inside a foreach over the same collection
- ✅ Everything lives in System.Collections.Generic — remember the using
Practice quiz
What is the key difference between an array and a List<T>?
- An array can grow; a List<T> is fixed-size
- They are identical
- An array is fixed-size; a List<T> is a resizable array that grows as you Add items
- A List<T> cannot be indexed
Answer: An array is fixed-size; a List<T> is a resizable array that grows as you Add items. An array has a fixed length set once. A List<T> is a resizable array that starts empty and grows automatically when you Add.
Which List<T> method appends an item to the end?
- Add()
- Push()
- Enqueue()
- Insert()
Answer: Add(). List<T>.Add(item) appends an item to the end of the list.
What is the index of the first item in a List<T>?
- 1
- -1
- It varies
- 0
Answer: 0. Indexes start at 0, so the first item is list[0] and the last is list[list.Count - 1].
What does a Dictionary<K,V> map?
- Positions to values
- Keys to values, with near-instant lookup by key (on average O(1))
- Values to values only
- Indexes to keys
Answer: Keys to values, with near-instant lookup by key (on average O(1)). A Dictionary maps each unique key to a value, and looking a value up by its key is on average O(1) regardless of size.
What is the safe way to read a Dictionary value that might be missing?
- TryGetValue(key, out value), which returns false instead of throwing
- dict[key], which returns null if missing
- dict.Get(key)
- There is no safe way
Answer: TryGetValue(key, out value), which returns false instead of throwing. dict[key] throws KeyNotFoundException for a missing key. TryGetValue returns false (and the value via out) instead of crashing.
What happens when you Add a duplicate value to a HashSet<T>?
- It throws an exception
- It stores the duplicate anyway
- It is silently ignored — the set keeps each value only once
- It clears the set
Answer: It is silently ignored — the set keeps each value only once. A HashSet holds only unique values; adding a duplicate is silently ignored (Add returns false) and the count doesn't change.
A Stack<T> processes items in which order?
- First-in, first-out (FIFO)
- Last-in, first-out (LIFO)
- Sorted order
- Random order
Answer: Last-in, first-out (LIFO). A Stack is LIFO — Push adds to the top, Pop removes the top, like a pile of plates.
Which pair of methods does a Queue<T> (FIFO) use to add and remove items?
- Push / Pop
- Add / Remove
- Insert / Delete
- Enqueue / Dequeue
Answer: Enqueue / Dequeue. A Queue is FIFO: Enqueue joins the back of the line and Dequeue serves the front.
What happens if you add to or remove from a collection while iterating it with foreach?
- Nothing — it works fine
- It throws InvalidOperationException: Collection was modified
- The loop silently skips items
- It doubles the collection
Answer: It throws InvalidOperationException: Collection was modified. Modifying a collection during a foreach over it throws 'Collection was modified'. Loop a copy (.ToList()) or use RemoveAll(...).
Which namespace must you import to use List, Dictionary, HashSet, Stack and Queue?
- System.Linq
- System.Text
- System.Collections.Generic
- System.IO
Answer: System.Collections.Generic. All of these generic collections live in System.Collections.Generic, so you need 'using System.Collections.Generic;'.
Continue this course
- Previous: Inheritance & Polymorphism
- Next: Exception Handling — Handle errors gracefully with try-catch-finally and custom exceptions
- Quick reference: C# cheat sheet › Collections