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; indexedfor
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.