Day 27: Collections Deep Dive - Beyond List and Dictionary
arrow_back All Posts
April 06, 2026 12 min read .NET/C#

Day 27: Collections Deep Dive - Beyond List and Dictionary

Yesterday you learned how to make your C# code talk to the internet — sending HTTP requests, consuming REST APIs, and deserializing JSON like a pro. Today we're turning inward. Forget the network; we're organizing the data that's already sitting in your program's memory. You've been using List<T> and Dictionary<TKey, TValue> like they're the only tools in the shed, but C# has an entire workshop full of specialized collections — and picking the right one can mean the difference between code that flies and code that crawls.

1. Beyond List and Dictionary — Meet the Full Collection Family

Back on Day 6, we introduced List<T> and Dictionary<TKey, TValue> — and they've served you well. They're the Swiss Army knives of C# collections. But here's the thing about Swiss Army knives: they're fine for opening a bottle, but you wouldn't use one to build a house.

The System.Collections.Generic namespace is packed with specialized collection types, each designed for a specific job:

Collection What It Does Think Of It As...
Stack<T> Last in, first out A stack of plates
Queue<T> First in, first out A line at a coffee shop
HashSet<T> Unique elements only A guest list — no duplicates
SortedSet<T> Unique + always sorted An alphabetized guest list
SortedDictionary<TKey, TValue> Key-value pairs, sorted by key A phone book
LinkedList<T> Fast insert/remove anywhere A chain — easy to add or remove links
ImmutableList<T> Read-only after creation A framed photograph

You don't need all of these every day. But when you do need one, nothing else will do the job as cleanly. Let's walk through them.

2. Stack — Last In, First Out

A Stack is exactly what it sounds like — imagine stacking plates in your kitchen. The last plate you put on top is the first one you take off. In computer science, this is called LIFO (Last In, First Out).

Where does this show up in real life? Your browser's back button. Every page you visit gets pushed onto a stack. When you hit back, the most recent page gets popped off. Undo history in text editors works the same way.

Stack<string> browserHistory = new();

// Visit some pages
browserHistory.Push("google.com");
browserHistory.Push("stackoverflow.com");
browserHistory.Push("github.com");

Console.WriteLine($"Current page: {browserHistory.Peek()}"); // github.com

// Hit the back button
string lastPage = browserHistory.Pop(); // removes github.com
Console.WriteLine($"Went back from: {lastPage}");
Console.WriteLine($"Now on: {browserHistory.Peek()}"); // stackoverflow.com

Console.WriteLine($"Pages in history: {browserHistory.Count}"); // 2

The three key methods are dead simple:

  • Push(item) — adds an item to the top of the stack
  • Pop() — removes and returns the top item (throws if empty)
  • Peek() — returns the top item without removing it (throws if empty)

Pro tip: use TryPop() and TryPeek() if the stack might be empty — they return bool instead of throwing an InvalidOperationException at your face.

3. Queue — First In, First Out

If a Stack is a pile of plates, a Queue is a line at the coffee shop. The first person in line gets served first. That's FIFO — First In, First Out.

Queues are everywhere in software: print queues, message processing pipelines, task schedulers, customer support ticket systems. Anything where "first come, first served" matters.

Queue<string> printQueue = new();

// Documents arrive for printing
printQueue.Enqueue("Resume.pdf");
printQueue.Enqueue("CoverLetter.docx");
printQueue.Enqueue("Meme.jpg"); // priorities, right?

Console.WriteLine($"Next to print: {printQueue.Peek()}"); // Resume.pdf

// Process the queue
while (printQueue.Count > 0)
{
    string doc = printQueue.Dequeue();
    Console.WriteLine($"Printing: {doc}");
}
// Output:
// Printing: Resume.pdf
// Printing: CoverLetter.docx
// Printing: Meme.jpg

The method names are a bit fancier than Stack's, but the concept is just as straightforward:

  • Enqueue(item) — adds an item to the back of the line
  • Dequeue() — removes and returns the item at the front
  • Peek() — peeks at the front item without removing it

Same deal as Stack — use TryDequeue() and TryPeek() when the queue might be empty. Your future self will thank you for not catching InvalidOperationException in production at 2 AM.

4. HashSet — Unique Elements Only

A HashSet is like a bouncer at a club with a strict "no duplicates" policy. You can try to add the same element ten times — it'll silently ignore every repeat and keep exactly one copy.

But the real magic isn't uniqueness. It's speed. A HashSet<T> uses hash-based lookups, which means checking whether an element exists is an O(1) operation — constant time, regardless of how many items are in the set. Compare that to List<T>.Contains(), which is O(n) — it has to check every single element.

HashSet<string> tags = ["csharp", "dotnet", "programming"];

// Try adding duplicates
bool added = tags.Add("csharp"); // returns false — already exists
Console.WriteLine($"Added 'csharp' again? {added}"); // False

tags.Add("blazor"); // returns true — new element

// Lightning-fast lookup
if (tags.Contains("dotnet"))
{
    Console.WriteLine("Found it!"); // O(1) — instant
}

Console.WriteLine($"Total unique tags: {tags.Count}"); // 4

HashSet<T> also supports set operations — the kind of thing you probably slept through in math class but suddenly becomes useful in code:

HashSet<string> mySkills = ["csharp", "javascript", "sql"];
HashSet<string> jobRequirements = ["csharp", "sql", "docker", "kubernetes"];

// What skills do I already have that the job needs?
HashSet<string> overlap = new(mySkills);
overlap.IntersectWith(jobRequirements);
Console.WriteLine($"Matching skills: {string.Join(", ", overlap)}"); // csharp, sql

// What do I still need to learn?
HashSet<string> missing = new(jobRequirements);
missing.ExceptWith(mySkills);
Console.WriteLine($"Skills to learn: {string.Join(", ", missing)}"); // docker, kubernetes

Use HashSet<T> whenever you care about uniqueness and fast lookups but don't care about ordering.

5. SortedSet and SortedDictionary — Always in Order

Sometimes you want uniqueness and you want your elements sorted at all times. That's where SortedSet<T> and SortedDictionary<TKey, TValue> come in.

A SortedSet<T> is like a HashSet<T> that keeps everything in sorted order automatically. You never have to call .Sort() — it just stays sorted as you add and remove elements.

SortedSet<int> scores = [85, 92, 78, 95, 88];

scores.Add(50);
scores.Add(99);

foreach (int score in scores)
{
    Console.Write($"{score} "); // 50 78 85 88 92 95 99
}
Console.WriteLine();

// Handy range queries
SortedSet<int> passing = scores.GetViewBetween(70, 100);
Console.WriteLine($"Passing scores: {string.Join(", ", passing)}"); // 78, 85, 88, 92, 95, 99

SortedDictionary<TKey, TValue> does the same thing but for key-value pairs — it keeps entries sorted by key. It's a Dictionary that always iterates in key order:

SortedDictionary<string, decimal> prices = new()
{
    ["Banana"] = 1.20m,
    ["Apple"] = 0.99m,
    ["Cherry"] = 3.50m
};

prices["Avocado"] = 2.00m;

foreach (var item in prices)
{
    Console.WriteLine($"{item.Key}: ${item.Value}");
}
// Apple: $0.99
// Avocado: $2.00
// Banana: $1.20
// Cherry: $3.50

The trade-off? Sorted collections use a balanced binary tree internally, so insertions and lookups are O(log n) instead of the O(1) you get with HashSet or Dictionary. That's still fast — but if you're stuffing millions of items in there and don't need sorting, stick with the unsorted versions.

6. LinkedList — Fast Insert and Remove in the Middle

List<T> is great — until you need to insert or remove elements in the middle of a large list. Because List<T> is backed by an array, inserting at position 0 means shifting every other element one spot to the right. That's O(n), and it hurts at scale.

LinkedList<T> solves this. It's a doubly-linked list — each element (called a node) holds a reference to both the next and previous nodes. Inserting or removing a node is O(1) once you have a reference to the node.

LinkedList<string> playlist = new();

// Add songs
playlist.AddLast("Bohemian Rhapsody");
playlist.AddLast("Hotel California");
playlist.AddLast("Stairway to Heaven");

// Insert a song after the first one
LinkedListNode<string>? firstSong = playlist.First;
if (firstSong is not null)
{
    playlist.AddAfter(firstSong, "Comfortably Numb");
}

// Remove from the middle — O(1), no shifting
playlist.Remove("Hotel California");

foreach (string song in playlist)
{
    Console.WriteLine(song);
}
// Bohemian Rhapsody
// Comfortably Numb
// Stairway to Heaven

Here's the catch — and it's a big one. LinkedList<T> has no index-based access. You can't do playlist[2]. You have to traverse from First or Last and walk node by node. So if you need random access by index, LinkedList<T> is the wrong tool.

When to actually use it? Honestly, rarely. List<T> is faster in practice for most workloads because of CPU cache locality — arrays sit in contiguous memory, which modern processors love. LinkedList<T> shines only when you're doing lots of insertions and removals in the middle of very large collections and you already have references to the nodes.

7. Choosing the Right Collection — A Decision Guide

This is the section you'll bookmark and come back to. Picking the right collection comes down to four questions:

Do you need key-value pairs?

  • Yes → Dictionary<TKey, TValue> (unsorted) or SortedDictionary<TKey, TValue> (sorted by key)
  • No → keep reading

Do you need uniqueness?

  • Yes → HashSet<T> (unsorted) or SortedSet<T> (sorted)
  • No → keep reading

Do you need a specific ordering pattern?

  • Last in, first out → Stack<T>
  • First in, first out → Queue<T>
  • Always sorted → SortedSet<T>
  • No → keep reading

What's your priority — fast lookup, fast insertion, or indexed access?

  • Fast lookup by value → HashSet<T>
  • Fast insert/remove in the middle → LinkedList<T>
  • Indexed access (items[3]) → List<T>
  • All of the above → sorry, that collection doesn't exist. Pick your trade-off.

Here's a quick-reference cheat sheet:

Need Collection Lookup Insert/Remove Indexed
General purpose List<T> O(n) O(n) middle, O(1) end
Key-value Dictionary<TKey, TValue> O(1) by key O(1)
Unique values HashSet<T> O(1) O(1)
Sorted unique SortedSet<T> O(log n) O(log n)
LIFO Stack<T> O(n) O(1) top
FIFO Queue<T> O(n) O(1) ends
Middle insert LinkedList<T> O(n) O(1) at node

Don't overthink it. For 90% of your code, List<T> and Dictionary<TKey, TValue> are the right answer. Reach for the specialized collections when you have a specific problem — not just because they sound cool on your resume.

8. Immutable Collections — Frozen in Time

Everything we've covered so far is mutable — you can add, remove, and modify elements after creation. But sometimes you want a collection that cannot be changed. Maybe you're sharing data between threads, or you want to guarantee that a method can't accidentally modify your list.

Enter the System.Collections.Immutable namespace. You'll need to add the NuGet package:

dotnet add package System.Collections.Immutable

Immutable collections return a new collection every time you "modify" them. The original stays untouched:

using System.Collections.Immutable;

ImmutableList<string> original = ImmutableList.Create("Alice", "Bob", "Charlie");

// "Adding" returns a NEW list — original is unchanged
ImmutableList<string> updated = original.Add("Diana");

Console.WriteLine($"Original count: {original.Count}"); // 3
Console.WriteLine($"Updated count: {updated.Count}");   // 4

// Same pattern for dictionaries
ImmutableDictionary<string, int> scores = ImmutableDictionary<string, int>.Empty
    .Add("Alice", 95)
    .Add("Bob", 87);

ImmutableDictionary<string, int> newScores = scores.SetItem("Bob", 92);

Console.WriteLine($"Bob's old score: {scores["Bob"]}");      // 87
Console.WriteLine($"Bob's new score: {newScores["Bob"]}");    // 92

This feels weird at first — "why would I want a collection I can't change?" — but immutability is a lifesaver in multithreaded code. If no one can change the collection, you don't need locks, you don't get race conditions, and you don't spend three hours debugging why an item vanished from a list that "nobody touched."

For building up immutable collections efficiently, use the builder pattern instead of chaining .Add() calls:

ImmutableList<int>.Builder builder = ImmutableList.CreateBuilder<int>();
for (int i = 0; i < 1000; i++)
{
    builder.Add(i); // mutable during construction
}
ImmutableList<int> numbers = builder.ToImmutable(); // frozen after this

The builder is mutable — fast for construction — and then you freeze it into an immutable collection when you're done. Best of both worlds.

9. Your Homework: Task Queue and Browser History

Time to put these collections to work. Build a console application that simulates two systems:

Part 1: Task Queue — Use Queue<T> to build a simple task processing system.

Queue<string> taskQueue = new();

// Simulate adding tasks
taskQueue.Enqueue("Send welcome email");
taskQueue.Enqueue("Generate invoice PDF");
taskQueue.Enqueue("Resize uploaded image");
taskQueue.Enqueue("Send push notification");
taskQueue.Enqueue("Update search index");

Console.WriteLine($"Tasks pending: {taskQueue.Count}");

// Process tasks one by one
int processed = 0;
while (taskQueue.Count > 0)
{
    string task = taskQueue.Dequeue();
    processed++;
    Console.WriteLine($"[Task {processed}] Processing: {task}");
    Thread.Sleep(500); // Simulate work
    Console.WriteLine($"[Task {processed}] Completed: {task}");
}

Console.WriteLine($"\nAll {processed} tasks processed!");

Part 2: Browser History — Use Stack<T> to simulate browser navigation with back/forward.

Stack<string> backStack = new();
Stack<string> forwardStack = new();
string currentPage = "home.html";

void Navigate(string url)
{
    backStack.Push(currentPage);
    currentPage = url;
    forwardStack.Clear(); // forward history dies on new navigation
    Console.WriteLine($"Navigated to: {currentPage}");
}

void GoBack()
{
    if (backStack.TryPop(out string? previous))
    {
        forwardStack.Push(currentPage);
        currentPage = previous;
        Console.WriteLine($"Back to: {currentPage}");
    }
    else Console.WriteLine("Nothing to go back to!");
}

void GoForward()
{
    if (forwardStack.TryPop(out string? next))
    {
        backStack.Push(currentPage);
        currentPage = next;
        Console.WriteLine($"Forward to: {currentPage}");
    }
    else Console.WriteLine("Nothing to go forward to!");
}

// Try it out
Navigate("about.html");
Navigate("blog.html");
Navigate("contact.html");
GoBack();       // back to blog.html
GoBack();       // back to about.html
GoForward();    // forward to blog.html
Navigate("faq.html"); // clears forward history
GoForward();    // Nothing to go forward to!

Bonus challenge: Add a HashSet<string> to track unique pages visited and display a "pages visited" count at the end.

Summary of Day 27

  • C# has specialized collections beyond List<T> and Dictionary — each optimized for a specific access pattern.
  • Stack<T> is LIFO — use it for undo history, back buttons, and expression parsing.
  • Queue<T> is FIFO — use it for task processing, message queues, and anything "first come, first served."
  • HashSet<T> guarantees uniqueness with O(1) lookups — perfect when you care about "is this in the set?" and nothing else.
  • SortedSet<T> and SortedDictionary keep elements in order automatically, at the cost of O(log n) operations.
  • LinkedList<T> allows O(1) insert/remove at any node — but you lose indexed access and cache-friendly memory layout.
  • Choosing the right collection comes down to four questions: key-value? uniqueness? ordering? access pattern?
  • Immutable collections can't be modified after creation — they return new copies instead, making them safe for concurrent code.
  • When in doubt, start with List<T> or Dictionary. Reach for specialized collections when you hit a specific performance or design need.

Tomorrow: we'll tackle Unit Testing — writing code that tests your code, so bugs don't survive past your desk. 🧪

See you on Day 28!

Share
FM

Farhad Mammadov

.NET Engineer & Cloud Architect · Bayern, Germany. Writing about scalable backend systems, AWS, and SRE.