Collection Best Practices - Write Clear, Fast, and Safe C# Collection Code

Vaibhav • September 10, 2025

By now, you’ve learned how to create arrays, work with List<string>, use Dictionary<string,int> and HashSet<string>, and iterate safely. This article distills everything into practical best practices you can apply immediately. We’ll stay within Chapter 7’s scope (arrays and generic collections) and avoid new language features. Each tip includes short code examples and plain‑language reasoning so you know not only what to do, but why it helps.

We’ll reference concepts from earlier Chapter 7 articles (initialization, iteration, performance), but we won’t re‑teach them. The goal here is a ready‑to‑use checklist of habits that keep your code readable, robust, and efficient.

1) Choose the right collection first

Picking the right data structure at the start prevents most downstream problems. Use this mental map:

  • Array - fixed size, fastest indexing, minimal memory overhead.
  • List<string> - dynamic size, ordered; a great default for sequences you’ll grow/shrink.
  • Dictionary<string,int> - fast lookups by key; ideal for mappings and caches.
  • HashSet<string> - uniqueness + fast membership checks with Contains.
  • Queue<string> / Stack<int> - FIFO/LIFO processing; no random access.
// Membership: HashSet<string> beats List<string> for repeated Contains
var blockedUsers = new HashSet<string> { "ada", "grace" };
if (blockedUsers.Contains("ada"))
{
    Console.WriteLine("Access denied");
}

Why: Choosing a set or dictionary avoids repeated linear scans you’d get with a list.

2) Make intent obvious: clear names and tight scope

Name collections after the data they hold and define them as close as possible to where they’re used. Prefer singular names for single items and plural for collections.

// Good: the name tells readers what's inside
var pendingOrderIds = new List<int> { 11, 12, 13 };
for (int i = 0; i < pendingOrderIds.Count; i++)
{
    Console.WriteLine("Order #" + pendingOrderIds[i]);
}

Why: Clear naming reduces mental overhead. Tighter scope limits accidental misuse and eases testing.

3) Initialize cleanly and seed where helpful

Initializers keep small, known datasets readable and compact. They also act as easy‑to‑audit “reference data.”

// Small, fixed reference data reads well as an initializer
var countryCodes = new Dictionary<string, string>
{
    ["IN"] = "India",
    ["US"] = "United States",
    ["DE"] = "Germany"
};

Why: Declaring and seeding in one place avoids boilerplate and makes intent obvious at a glance.

4) Manage capacity consciously

If you know you’ll add many items, set an initial capacity for lists and dictionaries to reduce resizing overhead.

int estimated = 5000;
var users = new List<string>(estimated);      // reduces reallocation
var index  = new Dictionary<string, int>(estimated);

Why: Fewer resizes means fewer allocations and copies, which improves performance in hot paths.

5) Iterate with purpose: foreach to read, index loops to modify

Use foreach for read‑only traversal; switch to for with an index when you need to write back.

// Read-only pass
var fruits = new List<string> { "Apple", "Banana", "Cherry" };
foreach (string f in fruits)
{
    Console.WriteLine(f);
}

// Modify in-place (arrays/lists): use indexes
int[] data = { 1, 2, 3, 4 };
for (int i = 0; i < data.Length; i++)
{
    data[i] = data[i] * 2;
}

Why: Iteration style communicates your intent and prevents fragile patterns (like writing to a read‑only loop variable).

6) Don’t mutate collections during foreach

Changing a collection while it’s being enumerated is unsafe. Use either a backward index loop or the two‑pass pattern.

// Backwards removal (safe for List<int>)
var numbers = new List<int> { 1, 2, 3, 4, 5, 6 };
for (int i = numbers.Count - 1; i >= 0; i--)
{
    if (numbers[i] % 2 == 0)
        numbers.RemoveAt(i);
}

// Two-pass removal
var words = new List<string> { "alpha", "beta", "gamma", "delta" };
var toRemove = new List<string>();

foreach (string w in words)
{
    if (w.StartsWith("g"))
        toRemove.Add(w);
}

foreach (string w in toRemove)
{
    words.Remove(w);
}

Why: These patterns avoid invalidating the enumerator and throwing exceptions.

7) Prefer TryGetValue with dictionaries

For lookups that may or may not succeed, use TryGetValue instead of the indexer to avoid exceptions and double‑lookups.

var ages = new Dictionary<string, int> { ["Ada"] = 35 };
if (ages.TryGetValue("Ada", out int age))
{
    Console.WriteLine("Ada: " + age);
}
else
{
    Console.WriteLine("Unknown person");
}

Why: It’s explicit, efficient, and clearly communicates the “might not exist” scenario.

8) Enforce uniqueness with HashSet

If duplicates are illegal, encode that rule directly with a set rather than checking manually.

var emails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
emails.Add("[email protected]");
emails.Add("[email protected]"); // ignored due to case-insensitive comparer

Why: A set puts the uniqueness rule in the data structure itself. (If you don’t need case‑insensitive matching, omit the comparer.)

9) Don’t rely on dictionary/set ordering

The traversal order of Dictionary and HashSet isn’t guaranteed. If display order matters, copy to a list and sort.

var counts = new Dictionary<string, int> { ["b"] = 2, ["a"] = 1, ["c"] = 3 };
var items = new List<KeyValuePair<string,int>>(counts);
items.Sort((x, y) => string.Compare(x.Key, y.Key, StringComparison.Ordinal)); // a, b, c
foreach (var kv in items)
{
    Console.WriteLine(kv.Key + ": " + kv.Value);
}

Why: Sorting at the boundary (just before display) keeps business logic independent of incidental ordering.

10) Reuse buffers on hot paths: Clear() vs. reallocate

When you repeatedly build temporary lists in loops, prefer clearing and reusing them.

var buffer = new List<int>(capacity: 256);
for (int pass = 0; pass < 1000; pass++)
{
    buffer.Clear(); // keeps capacity for reuse
    // ... fill buffer ...
}

Why: Clear() resets count but preserves capacity, reducing allocations.

11) Validate inputs and handle empty collections gracefully

Small guards make calling code safer and reduce surprises.

void PrintAll(List<string> items)
{
    if (items == null || items.Count == 0)
    {
        Console.WriteLine("(no items)");
        return;
    }

    foreach (string s in items)
        Console.WriteLine(s);
}

Why: Simple checks avoid null‑reference issues and awkward special cases.

12) Don’t expose mutable internals accidentally

If your class stores data in a mutable list, avoid returning the live list directly. Return a copy instead.

// Internal storage
private static List<int> _scores = new List<int> { 10, 20, 30 };

// Safer: return a copy
static List<int> GetScoresCopy()
{
    return new List<int>(_scores);
}

// Or: return an array snapshot
static int[] GetScoresArray()
{
    return _scores.ToArray();
}

Why: Copies clarify ownership. Callers can’t mutate your internal state accidentally.

13) Know what gets copied (shallow vs. deep)

new List<string>(existing) and ToArray() copy the references for reference types, not the objects themselves. If you need deep copies, you must create them explicitly (beyond this chapter’s scope).

var original = new List<string> { "A", "B" };
var copy = new List<string>(original); // shallow: strings are immutable, so fine here

Why: Understanding copy behavior avoids accidental sharing and surprising updates.

14) Use bulk operations when available

Prefer AddRange (and similar) over many single adds.

var a = new List<int> { 1, 2, 3 };
var b = new List<int> { 4, 5, 6 };
a.AddRange(b); // typically fewer resizes and clearer intent

Why: Bulk operations can reduce overhead and express intent more clearly than repeated single calls.

15) Pick stable keys for dictionaries

A dictionary key should be stable over the lifetime of its entry and uniquely identify the value. For strings, avoid null keys; use consistent casing rules if necessary.

// Case-insensitive string keys (optional)
var catalog = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
catalog["Book"] = 100;
Console.WriteLine(catalog.ContainsKey("book")); // True

Why: Stable and consistently compared keys avoid “ghost duplicates” and missed lookups.

16) Separate transformation from traversal

Keep loop bodies short and focused: do one thing per pass. If a loop is doing multiple tasks, extract helpers so your traversal logic stays readable.

var scores = new List<int> { 3, 1, 4, 2 };
Normalize(scores);
scores.Sort(); // sorted after normalization

void Normalize(List<int> list)
{
    for (int i = 0; i < list.Count; i++)
        list[i] = list[i] * 10;
}

Why: Small, single‑purpose loops and helpers are easier to reason about and test.

17) Drain queues/stacks intentionally

When your goal is to process and clear the structure, use a simple “until empty” loop that removes items.

// Queue: FIFO
var q = new Queue<string>();
q.Enqueue("A"); q.Enqueue("B"); q.Enqueue("C");
while (q.Count > 0)
{
    string next = q.Dequeue();
    Console.WriteLine("Processing " + next);
}

// Stack: LIFO
var s = new Stack<int>();
s.Push(1); s.Push(2); s.Push(3);
while (s.Count > 0)
{
    int top = s.Pop();
    Console.WriteLine("Handling " + top);
}

Why: You won’t accidentally mix enumeration with mutation, and the code reflects the intended policy.

18) Measure before you optimize

Use simple timing to compare approaches in realistic conditions. Optimize only after you find actual hot spots.

var sw = new System.Diagnostics.Stopwatch();
sw.Start();
// ... do work with a collection ...
sw.Stop();
Console.WriteLine("Elapsed: " + sw.ElapsedMilliseconds + " ms");

For small collections, different structures often perform similarly. Differences become meaningful at larger scales or inside tight loops.

19) Keep error messages and logs near the data

When a collection‑related assumption fails (e.g., “must contain at least one item”), log a clear message close to the code that manipulates the data. This makes debugging faster and reduces guesswork later.

20) Prefer clarity over micro‑optimizations

A clear foreach or well‑named helper method is worth more than a tiny, unmeasured speedup. Only trade clarity for performance when measurements justify it.

Checklist - habits for better collection code

  • Pick the right collection: array (fixed), List<string> (dynamic), Dictionary<string,int> (keyed), HashSet<string> (unique), Queue<string>/Stack<int> (ordered processing).
  • Name by content, scope narrowly, and keep loops small and focused.
  • Use initializers for small fixed data; set capacity for large or growing data.
  • foreach to read; indexed for to modify.
  • Never mutate during foreach; remove backwards or in two passes.
  • Use TryGetValue for safe dictionary lookups; choose stable, well‑defined keys.
  • Use HashSet for uniqueness and fast membership checks.
  • Don’t rely on dictionary/set order; copy and sort for display.
  • Reuse buffers with Clear() in hot paths; avoid unnecessary copies.
  • Return copies instead of exposing live mutable collections.
  • Measure with realistic data before optimizing.

Summary

Collections are the backbone of everyday C# code. By choosing the right structure early, iterating with clear intent, avoiding mutation during enumeration, managing capacity deliberately, and returning safe copies, you’ll prevent many bugs and keep performance predictable. Use sets for uniqueness, dictionaries for key‑based lookups, and lists or arrays for ordered sequences depending on whether your size is dynamic or fixed. Keep loops small, names descriptive, and measurements honest. With these best practices, your collection code will stay clean, fast, and easy to maintain as your projects grow.