A practical, performance-focused guide to choosing the right .NET collection based on real behavior, trade-offs, and measured performance.
- Step 1: Use the Decision Flow Chart below to narrow down your architectural constraints.
- Step 2: Use the Quick Reference Matrix to confirm the exact data structure choice.
- Step 3: Click the collection names in the matrix or the catalog section to navigate directly to detailed behavior, trade-offs, and benchmarks.
Decisions are evaluated sequentially in this exact order:
- Thread Safety (Is data shared across multiple threads concurrently?)
- Data Lifetime (Is data dynamic/mutable, completely static, immutable, or a safe view?)
- Access Pattern (Are you looking up items by matching keys/values, or navigating sequentially?)
- Ordering Requirements (Do items need sorting, prioritization, FIFO, or LIFO?)
flowchart TD
A[Start] --> B{Thread Safety\nRequired?}
%% === CONCURRENT CONTEXT ===
B -->|Yes| C{Processing Pattern?}
C -->|Async Producer / Consumer| C1[Channel]
C -->|Blocking Producer / Consumer| E1[BlockingCollection]
C -->|Shared Key-Value Storage| E2[ConcurrentDictionary]
%% === MAIN LIFE CYCLE LIFETIME ===
B -->|No| H{How will this data change\nover time?}
%% Frozen Stream
H -->|Built once at startup\nNever changes, read millions of times| L{How will you look up data?}
L -->|By key mapping| M[FrozenDictionary]
L -->|By unique values only| N[FrozenSet]
%% ReadOnly View Stream
H -->|It changes internally, but I want to\nblock consumers from modifying it| R2{How will consumers access it?}
R2 -->|By key mapping| R3[ReadOnlyDictionary]
R2 -->|By index or list iteration| R4[ReadOnlyCollection]
%% Immutable Structural Stream
H -->|It never changes, but I need to make\nnew updated copies safely| I{What is the primary action?}
I -->|Mostly reading, rarely making a copy| J1[ImmutableArray]
I -->|Frequently adding/removing items to make new copies| J2[ImmutableList]
I -->|Looking up values by a key| J3[ImmutableDictionary]
I -->|Tracking unique items| J4[ImmutableHashSet]
%% Standard Mutable Stream
H -->|Standard collection\nFrequent Add, Remove, and Clear| O{How do you want to find\nitems in the collection?}
%% Lookups Sub-branch
O -->|Look up by matching a Key or value| P{Do you need the items\nto be sorted?}
P -->|No, just fast lookup| P1{Do you have a Key?}
P1 -->|Yes, map Key to Value| T2[Dictionary]
P1 -->|No, just check if Value exists| Q[HashSet]
P -->|Yes, sort by Key| P2{Is the collection large\nwith frequent additions?}
P2 -->|Yes, heavy additions| S[SortedDictionary]
P2 -->|No, mostly setup once / low memory| S2[SortedList]
%% Sequential Sub-branch
O -->|Look up by order or position| U{What is the order pattern?}
U -->|First-In, First-Out queue| V[Queue]
U -->|Last-In, First-Out stack| W[Stack]
U -->|By custom priority ranking| X[PriorityQueue]
U -->|Keep values sorted automatically| Y[SortedSet]
U -->|Direct index lookup like items| Z[List]
| Context / Category | Requirement & Behavior | Recommended Collection |
|---|---|---|
| Standard Mutable | Dynamic list of items with direct index access ([i]) |
List<T> |
| Standard Mutable | High-speed key-based data mapping | Dictionary<TKey, TValue> |
| Standard Mutable | High-speed uniqueness and existence checks | HashSet<T> |
| Standard Mutable | Process sequential items in arrival order (FIFO) | Queue<T> |
| Standard Mutable | Process sequential items in most-recent order (LIFO) | Stack<T> |
| Standard Mutable | Process sequential items ordered by a priority ranking | PriorityQueue<TElement, TPriority> |
| Standard Mutable | Kept sorted by key; optimized for frequent additions | SortedDictionary<TKey, TValue> |
| Standard Mutable | Kept sorted by key; optimized for frequent read lookups | SortedList<TKey, TValue> |
| Standard Mutable | Kept sorted by unique value elements | SortedSet<T> |
| Concurrent | Multi-threaded thread-safe key-value data storage | ConcurrentDictionary<TKey, TValue> |
| Concurrent | High-performance async producer–consumer messaging | Channel<T> |
| Concurrent | Multi-threaded thread-safe blocking pipeline messaging | BlockingCollection<T> |
| Immutable | Data never changes; optimized for maximum read performance | ImmutableArray<T> |
| Immutable | Data never changes; optimized for frequent structural updates | ImmutableList<T> |
| Immutable | Thread-safe, unchangeable key-value data storage | ImmutableDictionary<TKey, TValue> |
| Immutable | Thread-safe, unchangeable set of unique elements | ImmutableHashSet<T> |
| Static / Frozen | Created once at app startup; optimized key lookups | FrozenDictionary<TKey, TValue> |
| Static / Frozen | Created once at app startup; optimized unique sets | FrozenSet<T> |
| Read-Only View | Safe, unmodifiable view tracking a dynamic underlying list | ReadOnlyCollection<T> |
| Read-Only View | Safe, unmodifiable view tracking dynamic key-value data | ReadOnlyDictionary<TKey, TValue> |
- Using
List<T>.Containsinside loops: Forces sequential scans from index 0 to N on every single check. Convert the collection to aHashSet<T>to gain instant lookups. - Modifying
ImmutableArray<T>in sequential loops: Calling.Add()allocates a brand-new underlying array copy on every single iteration. UseImmutableArray.CreateBuilder<T>()to populate data in memory before freezing it. - Leaving collection capacities unassigned: Instantiating collections without a size hint triggers multiple array reallocations and memory copies as elements grow. Always pass an estimated size parameter to constructors like
new List<T>(capacity). - Using structural keys in dictionaries without equality contract overrides: Passing custom
structconfigurations as dictionary keys without explicitly implementingIEquatable<T>and overridingGetHashCode()triggers heavy CPU boxing overhead and runtime reflection lookups on every access. - Double-looking up dictionary keys during updates: Checking
ContainsKey()immediately followed by an indexer update executes two separate hash table lookups. UseTryGetValue()orCollectionsMarshal.GetValueRefOrAddDefaultto perform the entire operation in a single step. - Iterating
Dictionary.KeysorDictionary.Valuesto look up the matching pair: Scanning one of the side-collections sequentially destroys the purpose of a map. UseKeyValuePair<TKey, TValue>iteration directly viaforeach (var (key, value) in dict)to access keys and values together without extra lookups or array allocations. - Passing an active mutable collection to a
ReadOnlyCollection<T>wrapper while continuing to modify the source: Modifying the originalList<T>after creating the wrapper will instantly change the wrapper's content. If you require a true snapshot that cannot change from underneath you, use.ToFrozenSet(),.ToFrozenDictionary(), or an immutable equivalent. - Using
ConcurrentBag<T>as a generic substitute forConcurrentQueue<T>in dedicated producer-consumer tracks: When distinct threads only add items and completely different threads only pull them out,ConcurrentBag<T>forces constant cross-thread work-stealing synchronization locks. UseConcurrentQueue<T>for separated workloads. - Overusing
SortedDictionary<TKey, TValue>when updates are rare:SortedDictionaryuses a binary search tree, which incurs heavy object allocation overhead for its nodes. If your collection is populated once or twice and then primarily read,SortedList<TKey, TValue>is vastly superior because it stores elements in tightly packed arrays, saving substantial memory and improving CPU cache locality. - Enqueuing large objects into
PriorityQueuewith a complex, unoptimized priority type: When sorting elements dynamically,PriorityQueueexecutes structural comparison swaps. If your priority parameter is a large mutable custom struct rather than a simple primitive (like anintor along), .NET is forced to copy that value type across memory boundaries on every swap, driving up CPU cycles during updates. - Using
ConcurrentDictionary.GetOrAddwith a lambda that allocates closure memory: If your factory lambda references variables defined outside of its scope, the compiler secretly instantiates a hidden closure object on the heap on every single execution. Use the performance-oriented overloadGetOrAdd(key, (k, state) => factory(k, state), factoryState)to pass external variables safely into the factory state context without allocations. - Treating
ConcurrentDictionary.Countas a cheap property check: Unlike a standard collection where checking the count reads a pre-calculated field, calling.Counton aConcurrentDictionaryforces the runtime to acquire locks sequentially across every single internal bucket partition to sum up the items safely. For presence checks in multi-threaded tracks, try iterating with.IsEmptyinstead. - Failing to use
PriorityQueue.DequeueEnqueuefor rapid replacement loops: When you need to pop the top priority item out and immediately push a replacement element in, calling.Dequeue()followed by.Enqueue()forces the internal binary heap structure to re-balance itself twice. Using the single combined.DequeueEnqueue()method executes both actions in a single optimized pass, cutting the structural sorting work in half. - Instantiating a
Queue<T>orStack<T>when you frequently need to check middle elements: Queues and stacks are array-backed structures strictly optimized for entry-point and exit-point manipulations. If you regularly look up items in the middle of the chain using loops or linear scans, you lose the layout benefit. Use a standardList<T>which naturally supports fast direct indexing. - Using
List<bool>orbool[]arrays for massive flags or binary toggle structures: In .NET, abooltype actually consumes a full byte (8 bits) of memory storage. If you are holding millions of binary status toggles, a standard list wastes massive amounts of RAM. Switching to aBitArraycompacts your data tightly by storing up to 32 independent boolean flags inside a single 32-bit integer block. - Using
ImmutableList<T>when you only need a snapshot of an array: If you just want to freeze an existing array or list so it cannot be mutated, calling.ToImmutableList()creates an expensive, multi-node binary tree structure in memory. If you only plan to read from the snapshot, use.ToImmutableArray()instead, which simply wraps a raw array with zero tree overhead. - Holding references to small sub-arrays using
Array.Copyor standard slicing instead ofReadOnlySpan<T>: When you slice or extract a subset of elements from a large array into a new collection, .NET allocates a brand-new array on the heap and copies the values over. For read-only processing on a window of data, useReadOnlySpan<T>orMemory<T>to pass a zero-allocation virtual view pointing directly to the original memory layout. - Assuming
ReadOnlyCollection<T>protects the elements inside it from being changed: A read-only wrapper only stops a consumer from adding, removing, or replacing items in the list structure itself. If the collection holds mutable class objects, external code can still freely modify the properties of the individual objects inside the list. To achieve absolute safety for reference types, the objects themselves must be designed as immutable. - Casting a collection to
IEnumerable<T>to pass it to a method, assuming it prevents modification: Casting aList<T>or aDictionary<TKey, TValue>down to a base interface likeIEnumerable<T>hides the mutation methods from the compiler, but it does not change the runtime object. A malicious or careless consumer can easily cast the interface back toIList<T>orList<T>at runtime and completely modify your private collection state.
- List — Array-backed indexable dynamic list.
- LinkedList — Node-backed dynamic sequence for fast center updates.
- Queue — First-In, First-Out sequence behavior.
- Stack — Last-In, First-Out sequence behavior.
- Dictionary — High-speed unique key-to-value map storage.
- HashSet — High-speed membership validation collection.
- SortedList — Fast-reading, memory-efficient sorted key-value array.
- SortedDictionary — Fast-updating binary tree sorted key-value map.
- SortedSet — Automatically sorted unique values element tree.
- Channel — High-performance async producer–consumer messaging streams.
- BlockingCollection — Multi-threaded thread-safe blocking pipeline messaging boundary.
- ConcurrentDictionary — Multi-threaded shared state caching and unique map storage.
- FrozenDictionary — App configuration lookup map created once at application startup.
- FrozenSet — Fixed membership verification pool optimized for maximum read performance.
- ImmutableArray — Thread-safe, unchangeable array layout optimized for raw indexing lookups.
- ImmutableList — Functional code sequence snapshots optimized for frequent structural updates.
- ImmutableDictionary — Thread-safe, unchangeable key-value data storage.
- ImmutableHashSet — Thread-safe, unchangeable set of unique elements.
- ReadOnlyCollection — Defensive shielding proxy view tracking a dynamic underlying list.
- ReadOnlyDictionary — Defensive shielding proxy view tracking dynamic key-value data.