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

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

CollectionOrderDuplicatesFind an item by…Best for
array (int[])✅ Index✅ YesPositionFixed, known size
List<T>✅ Index✅ YesPositionGrowable ordered lists
Dictionary<K,V>❌ NoUnique keysKeyFast lookup by name/ID
HashSet<T>❌ No❌ NoMembershipUnique items, set maths
Stack<T>LIFO✅ YesTop onlyUndo, backtracking
Queue<T>FIFO✅ YesFront onlyTask 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 up

2. 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
//      Date

Your 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: 4

Now 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: dotnet

5. 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: 1

This "count occurrences" pattern is everywhere — tallying votes, log levels, inventory, survey answers. Learn it once and you'll reuse it constantly.

Pro Tips

Common Errors (and the fix)

📋 Quick Reference

TaskCodeResult
New listnew List<string>()empty list
Add an itemlist.Add("x")appends "x"
Read by indexlist[0]first item
How many?list.Countitem count
Contains?list.Contains("x")true / false
Add to dictdict["k"] = 5sets/overwrites
Safe lookupdict.TryGetValue("k", out v)true / false
Key exists?dict.ContainsKey("k")true / false
Stack add / takePush(x) / Pop()LIFO
Queue add / takeEnqueue(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

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