Skill v1.0.0
currentAutomated scan100/100version: "1.0.0" name: csharp-collections-modern description: Use when writing or reviewing C#/.NET code that builds, exposes, or iterates collections — collection expressions and target typing, params collections/spans, choosing between Dictionary/HashSet (the default), FrozenDictionary/FrozenSet (rarely, only for a long-lived build-once/read-many table), ImmutableArray/ImmutableDictionary, arrays, and List<T>, using concrete collection types instead of IList/ICollection/IDictionary/ISet interfaces, Span<T>/stackalloc for local work, CollectionsMarshal, and when LINQ is fine vs when it isn't.
Modern collections (C# 13/14, .NET 10)
Collection choice is an API and performance decision, not a style preference. Pick the narrowest type that fits how the data is built, read, and exposed.
Collection expressions
[...] is target-typed — the compiler picks the concrete type from context, not from the syntax:
int[] arr = [1, 2, 3]; // T[]List<int> list = [1, 2, 3]; // List<T>Span<int> span = [1, 2, 3]; // stack-allocated for a ref struct targetint[] a = [1, 2];int[] combined = [.. a, 5, 6]; // spread — concatenates/copies
For interface targets, the compiler synthesizes a read-only wrapper over an array, not a List<T>:
IEnumerable<int> ie = [1, 2, 3]; // compiler-generated read-only array wrapperIReadOnlyList<int> irl = [1, 2, 3]; // same — not a List<T>, don't downcast to one
Don't cast a collection-expression result to List<T> expecting it to succeed — the concrete type is an implementation detail. If you need List<T> specifically, target-type to it explicitly.
The spread element .. works on any type enumerable and lets the compiler pick a single-allocation, correctly-sized result — prefer it over Concat().ToArray() chains.
.NET 11 / C# 15 — collection expression arguments
Check TargetFramework/LangVersion first (see csharp-language-versions) — needs net11.0+/C# 15; don't raise LangVersion on an older-targeted project just for this. A collection expression can now take a leading with(...) argument list to configure the collection being built — capacity, comparer, or both — instead of falling back to imperative construction:
List<string> names = [with(capacity: 100), .. values]; // pre-sized List<T>HashSet<string> set = [with(StringComparer.OrdinalIgnoreCase), "a", "A"]; // custom comparer, still target-typed
with(...) must be the first element and only applies when the target type has a matching constructor/factory shape (e.g. List<T>(int capacity), HashSet<T>(IEqualityComparer<T>)) — it's resolved the same way collection-expression construction already is, just with arguments threaded through instead of a bare new(). Prefer this over new List<T>(capacity) { ... }/new HashSet<T>(comparer) { ... } boilerplate once the project targets net11.0+.
.NET 11 library additions worth knowing
- `EqualityComparer<T>.Create<TKey>(Func<T,TKey> keySelector, IEqualityComparer<TKey>? keyComparer = null)` — build a comparer from a key projection without hand-writing
IEqualityComparer<T>:
``csharp var byLength = EqualityComparer<string>.Create(s => s.Length); var set = new HashSet<string>(byLength); ` (There's also a Create(Func<T?,T?,bool> equals, Func<T,int>? getHashCode = null)` overload for a fully custom comparison.)
- LINQ `FullJoin` — outer join that keeps unmatched rows from both sides (filling the missing side with
default/a supplied value), whereJoinonly keeps matches andGroupJoingroups matches per outer element. - Tuple-returning `Join`/`GroupJoin` overloads — skip the result-selector lambda when you just want the pairing:
left.Join(right, l => l.Key, r => r.Key)returns(TLeft, TRight)tuples directly;left.GroupJoin(right, l => l.Key, r => r.Key)returnsIEnumerable<IGrouping<TKey, TRight>>directly, no selector needed. - `BitArray` span constructors —
new BitArray(ReadOnlySpan<bool> values)alongside the existingbool[]/int[]/byte[]constructors, so astackalloc/slice can seed aBitArraywithout an intermediate array.
params collections and spans (C# 13)
params is no longer array-only. Prefer params ReadOnlySpan<T> for hot-path APIs — the compiler stack-allocates the backing storage for small, fixed call sites instead of heap-allocating an array:
static int Sum(params ReadOnlySpan<int> values){var total = 0;foreach (var v in values) total += v;return total;}Sum(1, 2, 3); // no array allocationSum([4, 5, 6]); // collection expression also binds
Rules:
params ReadOnlySpan<T>/params Span<T>cannot be used inasyncmethods or iterators (spans can't cross anawaitor be captured in state machines) — keep thoseparams T[].- A type only needs a
GetEnumerator(), indexer, andCount/Length(a "collection-like" shape) to supportparamsnow — you can author your ownparams-friendly collection types. - Don't change an existing public
params T[]to a span overload casually — it's a binary-breaking, source-compatible-only change; add an overload if both call shapes matter.
Choosing a collection type
| Need | Use | |
|---|---|---|
| Default lookup/set — mutable or not, most processes | Dictionary<K,V> / HashSet<T> | |
| Tiny fixed set of cases known at compile time | array, or a switch expression — no collection at all | |
| Local, mutable, size known or grows during a method | List<T> / T[] | |
| Fixed-size, no growth, tight loops, best cache locality | T[] | |
| Small, fixed-size, value-semantics collection you compare/hash | ImmutableArray<T> | |
| Need structural immutability with cheap incremental "with" edits | ImmutableDictionary<K,V> / ImmutableList<T> | |
| Long-running process, one lookup table built once at startup, read millions of times after — and you've measured construction is amortized away | FrozenDictionary<K,V> / FrozenSet<T> |
- `FrozenDictionary`/`FrozenSet` are rarely the right choice — treat them as an exception, not a default. Construction analyses the actual key set to build a specialized layout (perfect-hash-style branching tuned for that data), which is meaningfully more expensive to build than a
Dictionary/HashSet. That cost is only worth paying when the collection is built exactly once in a long-running process and then queried a very large number of times afterward — e.g. a server's static routing table or a compiler's fixed keyword set, built at startup and read for the life of the process. - Do not reach for `Frozen*` for: short-lived processes, CLI tools, tests, analyzers/generators, request/response-scoped data, anything rebuilt per request or per compilation, or "it's read-only so it should be Frozen" reasoning. In all of these the construction cost is paid on (or near) every use, which makes it slower than
Dictionary/HashSet, not faster. - Default to `Dictionary<K,V>`/`HashSet<T>` (or a plain array/
switchfor a handful of fixed cases) for lookup tables. Only move toFrozen*after profiling shows construction is amortized over enough reads to matter — measure with BenchmarkDotNet including construction cost, not just steady-state lookup time, before switching. - `ImmutableArray<T>`/`ImmutableDictionary<K,V>` give you real structural immutability and safe sharing across threads without copy-on-write bookkeeping in your own code —
ImmutableDictionaryedits areO(log n)persistent-tree operations, not full copies, but that means it is slower to read than a plainDictionary. Don't reach forImmutableDictionaryas a "safe default" for something only ever read — that's still justDictionaryterritory unless you need the structural-sharing/edit semantics. - Plain
Dictionary<K,V>/List<T>/arrays remain correct and fastest for anything mutable or short-lived. Don't addFrozen/Immutableceremony to a collection nobody shares or freezes. ImmutableArray<T>is a thin struct wrapper overT[]— cheap to pass around, but adefaultinstance is not the same asEmpty(it's null-backed and throws on iteration). Always initialize it.
// ✅ default choice — cheap to build, fast enough for almost everythingstatic readonly Dictionary<string, Command> Commands =new() { ["run"] = Run, ["stop"] = Stop };// ✅ Frozen only once measurement justifies it: built once at process startup,// read millions of times over a long-running server's lifetimestatic readonly FrozenDictionary<string, Route> Routes =LoadRoutesFromConfig().ToFrozenDictionary();// ❌ rebuilding per call pays the (larger) construction cost on every use —// this is slower than a plain Dictionary, not fasterFrozenDictionary<string, Command> Lookup() => _raw.ToFrozenDictionary(); // called per request
Use concrete collection types, not collection interfaces
Declare collections with their concrete types: List<T>, Dictionary<TKey, TValue>, HashSet<T>, T[], ImmutableArray<T>. Don't use the mutable collection interfaces (IList<T>, ICollection<T>, IDictionary<TKey, TValue>, ISet<T>) for fields, locals, parameters or return types. This matches the .NET analyzer guidance (CA1859, "use concrete types when possible for improved performance"):
- Speed: calls through a concrete type are direct and can be inlined; through an interface they're virtual dispatch.
foreachover aList<T>uses its struct enumerator; overIList<T>the enumerator is boxed on older runtimes. - Clarity: the concrete type tells the reader exactly what they have: its complexity, ordering, whether it's a copy.
- Honesty: the mutable interfaces promise operations that often throw (
IList<T>.Addon an array, a read-only wrapper), so the type guarantees nothing.
// ✅ concrete types throughoutprivate readonly Dictionary<string, Order> _byId = [];public List<Order> FindOpen(HashSet<string> ids) { ... }// ❌ interface types that hide what the collection is and cost dispatchprivate readonly IDictionary<string, Order> _byId = new Dictionary<string, Order>();public IList<Order> FindOpen(ICollection<string> ids) { ... }
Narrow exceptions:
- `IEnumerable<T>` for a genuinely lazy or streaming sequence (an iterator, a query you don't want to materialise). Anything already materialised goes out as its concrete type.
- `IReadOnlyList<T>` / `IReadOnlyDictionary<TKey, TValue>` only when a public API deliberately exposes a read-only view of internal state without copying it. The view is a contract, not protection: a caller can cast back to
List<T>. ReturnImmutableArray<T>or a copy when the guarantee matters. Reach forFrozenDictionary<TKey, TValue>only for the long-lived, build-once table discussed above, not by default.
private readonly List<Order> _orders = [];public IReadOnlyList<Order> Orders => _orders; // read-only view of internal state, no copy
Span<T> / ReadOnlySpan<T> / stackalloc for local work
Use spans for slicing and short-lived buffers that never escape the method:
ReadOnlySpan<char> csv = "a,bb,ccc";var comma = csv.IndexOf(',');ReadOnlySpan<char> first = csv[..comma]; // slice, no allocationSpan<int> scratch = stackalloc int[16]; // small, fixed, stack-only
- Spans are
ref struct— cannot be a field of a non-ref structclass, cannot be boxed, cannot crossawait/yield. That's a feature: it keeps them from escaping. stackallocis for small, bounded sizes known not to blow the stack (tens to low hundreds of elements) — never with an unbounded/user-controlled length.- Slice instead of
Substring/ToArray/Skip().Take()when you're only reading a subrange.
CollectionsMarshal — controlled escape hatches
For hot paths where the collection API forces an extra lookup or copy:
// mutate a List<T>'s backing storage in place, no copySpan<int> span = CollectionsMarshal.AsSpan(list);span[0] = 42;// one lookup instead of ContainsKey + indexer/Addref int slot = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, key, out var existed);if (!existed) slot = ComputeInitial();slot++;
Caveats: AsSpan is invalidated by any operation that resizes the list (Add past capacity, RemoveAt, etc.) — don't mutate the list's shape while holding the span. GetValueRefOrAddDefault returns a ref into the dictionary's internal storage — it's invalidated by any subsequent resize of the dictionary, so use it immediately, not stashed across other mutations.
LINQ in hot paths
LINQ is fine on cold/startup paths and for readability — the iterator and delegate overhead is real but usually irrelevant off the hot path. On measured hot loops (per-frame, per-request, per-notification), prefer a manual for/foreach — see csharp-performance for the allocation reasoning.
Newer LINQ methods worth knowing (avoid hand-rolled equivalents):
words.CountBy(w => w[0]); // group + count in one passwords.AggregateBy(w => w[0], 0, (acc, w) => acc + w.Length); // group + fold, no intermediate groupingswords.Index(); // (int Index, T Item) pairs, replaces .Select((w, i) => (i, w))
Avoid multiple enumeration. An IEnumerable<T> from a LINQ query or a yield return iterator re-runs its whole pipeline (including side effects and I/O) on every enumeration:
// ❌ query runs twiceIEnumerable<int> Query() => data.Where(x => x > 0);var q = Query();if (q.Any()) Process(q.ToList()); // enumerated once for Any, again for ToList// ✅ materialize once, reusevar results = Query().ToList();if (results.Count > 0) Process(results);
If a method returns IEnumerable<T> and the caller might enumerate more than once, materialize (ToList/ToArray) at the boundary rather than letting each call site guess.
Checklist
- [ ] Collection-expression target type is the one you actually want (
IReadOnlyList<T>/IEnumerable<T>are notList<T>) - [ ]
params ReadOnlySpan<T>on hot-path variadic APIs, notasync/iterator methods - [ ] On
net11.0+/C# 15:[with(capacity: n), ...]/[with(comparer), ...]used instead ofnew List<T>(n) { ... }boilerplate where the TFM allows it - [ ]
Dictionary/HashSetby default;Frozen*only in a long-running process, with a benchmark that includes construction cost - [ ]
Immutable*only where structural sharing/immutability is actually needed - [ ] Collections declared as concrete types (
List<T>,Dictionary<TKey, TValue>,HashSet<T>,T[]), neverIList<T>/ICollection<T>/IDictionary<TKey, TValue>/ISet<T> - [ ]
IEnumerable<T>only for lazy sequences;IReadOnlyList<T>/IReadOnlyDictionary<TKey, TValue>only for a deliberate read-only view of internal state - [ ] Spans/
stackallocstay local, bounded, and never crossawait - [ ]
CollectionsMarshalrefs/spans used immediately, not held across a resize - [ ] LINQ avoided on measured hot loops; no query re-enumerated by accident