Collections and LINQ in Depth

List<T> and Dictionary<K,V> deep dive, GroupBy and Join, and deferred execution pitfalls.

List<T> in depth

List<T> is C#'s general-purpose, resizable, ordered collection — backed by an array that automatically grows as needed, making it the right default for most sequences you'll build up over time:

C#
var names = new List<string> { "Ada", "Grace", "Alan" };

names.Add("Barbara");
names.Insert(0, "Katherine");        // insert at a specific index — O(n), shifts everything after it
names.Remove("Alan");                 // removes the first matching value — O(n) search + shift
names.RemoveAt(0);                    // removes by index

Console.WriteLine(names.Count);       // 3
Console.WriteLine(names.Contains("Ada"));  // true
Console.WriteLine(names.IndexOf("Grace")); // 0

names.Sort();                         // in-place alphabetical sort
names.Reverse();                      // in-place reversal

var found = names.Find(n => n.StartsWith("G"));   // first match, or null for a reference type

Indexed access (names[0]) and appending to the end (.Add(...)) are both O(1) on average; inserting or removing anywhere other than the end is O(n), since every following element has to shift. That's the practical trade-off versus LinkedList<T> (rare in everyday C# code, but available), which is O(1) to insert/remove at a known node but O(n) to reach an arbitrary index at all.

Dictionary<TKey, TValue> in depth

Dictionary<TKey, TValue> maps unique keys to values with O(1) average-case lookup, insertion, and removal — the standard choice whenever you need "look this up by some identifier" rather than "walk through everything in order":

C#
var prices = new Dictionary<string, decimal>
{
    ["Keyboard"] = 49.99m,
    ["Mouse"] = 19.99m,
};

prices["Monitor"] = 199.99m;          // add or overwrite

if (prices.TryGetValue("Keyboard", out decimal price))
{
    Console.WriteLine(price);          // 49.99
}

// prices["Webcam"];                  // throws KeyNotFoundException — the key doesn't exist
Console.WriteLine(prices.ContainsKey("Mouse"));   // true

foreach (var (name, cost) in prices)   // deconstructing each KeyValuePair<string, decimal>
{
    Console.WriteLine($"{name}: {cost:C}");
}

prices.Remove("Mouse");

TryGetValue is the idiomatic way to read a possibly-missing key — it returns false instead of throwing, avoiding both an unhandled KeyNotFoundException and a separate, redundant ContainsKey check followed by an indexer lookup (which would search the dictionary twice).

Choosing the right collection

Collection Ordered Lookup by key Duplicates Typical use
List<T> Yes (insertion order) By index only Yes A general-purpose, growable sequence
Dictionary<TKey, TValue> No guaranteed order By key, O(1) average Keys must be unique Fast lookups by an identifier
HashSet<T> No guaranteed order By value, O(1) average No Uniqueness, fast membership tests, set operations
Queue<T> Yes (FIFO) No Yes First-in-first-out processing (a work queue)
Stack<T> Yes (LIFO) No Yes Last-in-first-out processing (undo history, backtracking)

LINQ beyond Where/Select: grouping

GroupBy clusters elements sharing a common key into groups — each group is itself a sequence, accessible via its .Key:

C#
public record Product(string Name, decimal Price, string Category);

var products = new List<Product>
{
    new("Keyboard", 49.99m, "Electronics"),
    new("Mouse", 19.99m, "Electronics"),
    new("Desk", 199.99m, "Furniture"),
    new("Chair", 149.99m, "Furniture"),
};

var byCategory = products.GroupBy(p => p.Category);

foreach (var group in byCategory)
{
    Console.WriteLine($"{group.Key}:");
    foreach (var product in group)
    {
        Console.WriteLine($"  {product.Name} - {product.Price:C}");
    }
}
// Electronics:
//   Keyboard - $49.99
//   Mouse - $19.99
// Furniture:
//   Desk - $199.99
//   Chair - $149.99

var categorySummary = products
    .GroupBy(p => p.Category)
    .Select(g => new { Category = g.Key, Total = g.Sum(p => p.Price), Count = g.Count() });

foreach (var summary in categorySummary)
{
    Console.WriteLine($"{summary.Category}: {summary.Count} items, {summary.Total:C} total");
}
// Electronics: 2 items, $69.98 total
// Furniture: 2 items, $349.98 total

LINQ joins

Join combines two sequences on a matching key — the same relational idea as a SQL INNER JOIN, expressed over in-memory collections:

C#
public record Category(int Id, string Name);
public record Item(string Name, int CategoryId);

var categories = new List<Category>
{
    new(1, "Electronics"),
    new(2, "Furniture"),
};

var items = new List<Item>
{
    new("Keyboard", 1),
    new("Desk", 2),
    new("Mouse", 1),
};

var joined = items.Join(
    categories,
    item => item.CategoryId,        // key selector on the outer sequence (items)
    category => category.Id,        // key selector on the inner sequence (categories)
    (item, category) => new { item.Name, Category = category.Name }
);

foreach (var row in joined)
{
    Console.WriteLine($"{row.Name} -> {row.Category}");
}
// Keyboard -> Electronics
// Desk -> Furniture
// Mouse -> Electronics

For a "left outer join" — keeping items even when there's no matching category — combine GroupJoin with DefaultIfEmpty, or, in many real codebases, simply reach for query syntax, which reads closer to SQL for this specific case:

C#
var leftJoin =
    from item in items
    join category in categories on item.CategoryId equals category.Id into itemCategories
    from category in itemCategories.DefaultIfEmpty()
    select new { item.Name, Category = category?.Name ?? "Uncategorized" };

Deferred execution pitfalls

The advanced-csharp page in this track introduced deferred execution — a LINQ query built with Where/Select/GroupBy/etc. doesn't run until it's enumerated. That's usually a performance win (nothing is computed until actually needed), but it creates real, easy-to-hit pitfalls once a query captures a variable that changes before enumeration happens:

C#
var threshold = 50m;
var expensiveProducts = products.Where(p => p.Price > threshold);   // NOT executed yet

threshold = 200m;   // changing this AFTER building the query still affects it

foreach (var p in expensiveProducts)   // executes NOW, using threshold's CURRENT value (200), not 50
{
    Console.WriteLine(p.Name);         // only Desk (199.99) is actually excluded — surprising if you expected 50
}

A second, subtler pitfall: enumerating the same deferred query twice re-runs the entire query both times, which is wasted work for an expensive query and can produce different results if the underlying data changed in between:

C#
var query = products.Where(p => p.Category == "Electronics");

Console.WriteLine(query.Count());      // runs the filter once: 2

products.Add(new Product("Webcam", 79.99m, "Electronics"));

Console.WriteLine(query.Count());      // runs the filter again: 3 — the new product is now included too

Calling .ToList() (or .ToArray()) forces immediate execution, capturing a fixed snapshot of the results at that exact moment — the standard fix whenever a query's results need to stay stable regardless of what happens to the source collection or a captured variable afterward:

C#
var snapshot = products.Where(p => p.Category == "Electronics").ToList();  // executed right now, once

products.Add(new Product("Webcam", 79.99m, "Electronics"));

Console.WriteLine(snapshot.Count);   // still 2 — snapshot is a fixed List<Product>, not a live query

Common mistakes

  • Indexing into a Dictionary with a key that might not exist (prices["Webcam"]) instead of TryGetValue — it throws KeyNotFoundException rather than failing gracefully.
  • Capturing a variable inside a LINQ lambda and changing that variable's value before the query is enumerated, then being surprised the query used the variable's later value rather than the value at the time the query was written.
  • Enumerating the same deferred LINQ query multiple times (in a loop, or in more than one place) without realizing each enumeration re-runs the whole query from scratch — expensive for a costly query, and a source of inconsistent results if the underlying data changed in between.
  • Reaching for List<T>.Find/Contains/IndexOf (O(n) linear scans) in a hot path that does frequent lookups by identifier, when a Dictionary<TKey, TValue> would give O(1) average-case lookups for the same job.

Interview questions

Q: When does a LINQ query actually execute, and what's the most common bug that comes from misunderstanding this? It executes only when enumerated — via foreach, or a terminal call like .ToList(), .Count(), or .First() — not at the moment Where/Select/etc. are written. The most common resulting bug is capturing a variable inside the query's lambda, changing that variable afterward, and being surprised the query used the variable's value at enumeration time rather than at the time the query was built; a related bug is enumerating the same deferred query twice and getting different results because the underlying data changed in between.

Q: How would you decide between List<T> and Dictionary<TKey, TValue> for a given piece of data? If the data is primarily accessed by position, iterated in order, or needs duplicates preserved, List<T> is the right default. If the data is primarily looked up by some unique identifier — a user ID, a product SKU — Dictionary<TKey, TValue> gives O(1) average-case lookup instead of List<T>'s O(n) linear scan via Find/Contains, at the cost of not preserving a guaranteed iteration order and requiring genuinely unique keys.