System.Collections.Immutable types, their internal structures and costs, builders, and how they differ from read-only views and frozen collections.
Immutable collections (System.Collections.Immutable) never change after creation; "modifying" methods such as Add return a new collection and leave the old one untouched, so they are safe to share across threads without locks. ImmutableArray<T> is a struct around a plain array: reads are as fast as an array, but every Add copies the whole thing (O(n)). ImmutableList<T> and ImmutableDictionary<TKey,TValue> are balanced trees that share structure: an Add is O(log n) and reuses most of the old nodes, but reads are slower. Use builders (ToBuilder() ... ToImmutable()) for many changes. A read-only view like ReadOnlyCollection<T> or IReadOnlyList<T> only stops you changing it; the underlying list can still change. FrozenDictionary/FrozenSet (.NET 8+) are immutable too but optimised purely for fast reads with no "modified copy" operations.
Immutable collections (System.Collections.Immutable) can never change after creation: every "modification" returns a new collection, and the original stays valid. That makes them safe to share across threads and ideal for snapshots. I'd contrast the three main types, explain builders and structural sharing, then distinguish them from read-only views (ReadOnlyCollection<T>, IReadOnlyList<T>) — which are not immutable — and from frozen collections (FrozenDictionary), which are immutable but optimised for read speed instead of cheap updates.
| Type | Implementation | Read by index / lookup | "Add" | Best for |
|---|---|---|---|---|
ImmutableArray<T> |
struct wrapping a plain T[] |
O(1), as fast as an array | O(n) copy of the whole array | Read-heavy data that rarely changes; public API return types |
ImmutableList<T> |
Balanced AVL tree | O(log n) | O(log n), shares most nodes | Frequent incremental changes to a list-like snapshot |
ImmutableDictionary<TKey,TValue> |
Hash buckets in an AVL tree | O(log n) | O(log n), shares structure | Shared, versioned lookups updated over time |
ImmutableHashSet<T>, ImmutableSortedDictionary, ImmutableQueue, ImmutableStack |
Trees / linked structures | Varies | Varies | Specific shapes |
ImmutableList<string> v1 = ImmutableList.Create("a", "b");
ImmutableList<string> v2 = v1.Add("c"); // v1 still ["a","b"]; v2 is ["a","b","c"]
A classic bug: list.Add("c"); without assigning the result — nothing happens.
ImmutableList<T> and ImmutableDictionary are persistent data structures: an update creates new nodes only along the path from the root to the change (O(log n) nodes) and reuses every other node from the old version. So you can keep thousands of versions cheaply, and creating a new version doesn't copy everything.
ImmutableArray<T> has no structural sharing — it's a single array, so every change copies it. That's the trade-off: blazing reads, expensive writes.
Making many changes one at a time creates many intermediate versions. Builders are mutable, efficient accumulators that produce an immutable result at the end:
var builder = ImmutableArray.CreateBuilder<OrderLine>(initialCapacity: lines.Count);
foreach (var line in lines) builder.Add(Map(line));
ImmutableArray<OrderLine> result = builder.MoveToImmutable(); // no copy when Count == Capacity
var dictBuilder = ImmutableDictionary.CreateBuilder<string, Rate>(StringComparer.Ordinal);
// ... add many ...
var rates = dictBuilder.ToImmutable();
ToBuilder() on an existing collection gives a builder that shares structure until you change it. C# 12 collection expressions (ImmutableArray<int> a = [1, 2, 3];) use builders under the hood.
| Type | What it guarantees |
|---|---|
IReadOnlyList<T> / IReadOnlyCollection<T> |
An interface with no mutating methods. The underlying object may be a List<T> that someone else modifies, and a caller can cast back to List<T>. |
ReadOnlyCollection<T> (list.AsReadOnly()) |
A wrapper that blocks modification through itself, but is a live view: changes to the wrapped list show through. |
ImmutableArray<T> / ImmutableList<T> |
Truly immutable — nobody can change the contents. |
FrozenSet<T> / FrozenDictionary<TKey,TValue> (.NET 8) |
Truly immutable and optimised for lookups; no cheap updates (no Add returning a new version). |
Read-only means "you can't change it through this reference"; immutable means "nobody can change it".
FrozenDictionary analyses the keys once at creation (expensive) and then gives the fastest lookups of any .NET dictionary — often faster than Dictionary. Use it for configuration, routing tables, allow-lists: built once at start-up, read millions of times.ImmutableDictionary is slower to read (tree traversal, O(log n)) but supports cheap versioned updates with sharing. Use it when the data changes over time and readers need consistent snapshots.private ImmutableDictionary<string, FeatureFlag> _flags = ImmutableDictionary<string, FeatureFlag>.Empty;
public FeatureFlag? Get(string name) => _flags.GetValueOrDefault(name); // lock-free read of a snapshot
public void Set(string name, FeatureFlag flag) =>
ImmutableInterlocked.AddOrUpdate(ref _flags, name, flag, (_, _) => flag); // CAS loop, no lock
Readers always see a complete, consistent version; writers replace the reference atomically. This is how Roslyn, MSBuild and many configuration systems share state.
A multi-tenant API cached tenant settings in a Dictionary<string, TenantSettings> refreshed every minute by a background service while requests read it — occasionally causing InvalidOperationException and, once, a hung request (concurrent Dictionary writes can corrupt internal chains). Switching to building a new FrozenDictionary on each refresh and swapping the reference with Volatile.Write made reads lock-free and faster, and refreshes atomic. Because settings changed rarely and were read on every request, frozen (fast reads) beat ImmutableDictionary (cheap updates) for this case.
Checks whether the candidate understands the difference between read-only and immutable and knows the performance profile of each immutable type.
ImmutableArray copies on write while ImmutableList shares structure, uses builders, mentions ImmutableInterlocked and frozen collections.ReadOnlyCollection is immutable, adds to immutable collections in loops, or picks ImmutableList for hot reads.ImmutableArray<T> internalspublic readonly struct ImmutableArray<T> : IReadOnlyList<T>, IImmutableList<T>, IEquatable<ImmutableArray<T>>
{
internal readonly T[]? array;
}
foreach (struct enumerator) are as fast as a raw array; AsSpan() gives a ReadOnlySpan<T>.default(ImmutableArray<T>) has a null inner array; most members throw. Check IsDefault/IsDefaultOrEmpty, and initialise fields to [] or ImmutableArray<T>.Empty.==) compares the underlying array reference, not contents. Use SequenceEqual for content comparison — which matters if you put ImmutableArray<T> in records (record equality will be reference-based for that property).ImmutableCollectionsMarshal.AsImmutableArray(array) (.NET 8) wraps an existing array without copying — only safe if you never mutate that array again.ImmutableList<T> as an AVL treeEach node stores the element, left/right children, height and count of nodes in its subtree, which allows O(log n) indexing (list[i] walks left/right using subtree counts). Adding at the end creates a new path of O(log n) nodes and rebalances with rotations, sharing all untouched subtrees with the previous version.
Costs: each element is a node object (~40+ bytes), and reads chase pointers — so ImmutableList<T> is much slower to iterate and index than List<T> or ImmutableArray<T>. It shines only when you make many small updates while needing old versions to remain valid.
ImmutableDictionary internalsIt's a sorted AVL tree keyed by hash code, where each tree node holds a "bucket" (one value or a small immutable list for collisions). Lookup = hash → O(log n) tree search → compare keys in the bucket. Updates share structure like ImmutableList. Compared with Dictionary (O(1) array buckets), reads are typically several times slower.
A builder holds a mutable version of the same tree (or a List<T>-like buffer for ImmutableArray.Builder). Nodes created by the builder are marked as owned by it and are mutated in place; ToImmutable() "freezes" them, so the builder can reuse untouched nodes and doesn't copy everything. For ImmutableArray.Builder, MoveToImmutable() hands over the internal array without copying when Count == Capacity (otherwise it throws); ToImmutable() always copies.
ImmutableInterlockedImmutableInterlocked.Update(ref location, transformer) implements a compare-and-swap loop:
do { original = Volatile.Read(ref location); updated = transformer(original); }
while (Interlocked.CompareExchange(ref location, updated, original) != original);
If another writer changed the reference in between, it retries with the new version. The transformer must be pure (it may run multiple times). Helpers exist for dictionaries (AddOrUpdate, GetOrAdd, TryAdd, TryRemove), stacks and queues.
ToFrozenDictionary/ToFrozenSet choose among many specialised implementations based on the keys:
Construction can be 10–50× slower than building a Dictionary, so frozen collections pay off when the lookups per creation are large — typical for start-up-built lookup tables.
| Need | Choose |
|---|---|
| Return data callers can't change; data rarely changes | ImmutableArray<T> (or IReadOnlyList<T> backed by a fresh array) |
| Build once, read millions of times, no updates | FrozenDictionary / FrozenSet |
| Shared state updated over time, readers need snapshots | ImmutableDictionary/ImmutableList + ImmutableInterlocked |
| High-frequency concurrent writes | ConcurrentDictionary |
| Hide mutation from callers but keep a live internal list | ReadOnlyCollection<T> (aware it's a live view) |
Dictionary, ImmutableDictionary and FrozenDictionary with 1,000 string keys.ImmutableArray (O(n²)), ImmutableList, and an ImmutableArray.Builder.AsReadOnly(), modify the original list, and observe the wrapper.ImmutableInterlocked and hammer it from multiple threads.using System.Collections.Frozen;
using System.Collections.Immutable;
// 1. A view is not a snapshot
var inner = new List<string> { "a" };
IReadOnlyList<string> view = inner.AsReadOnly();
inner.Add("b");
Console.WriteLine(view.Count); // 2: the view sees the change
// 2. Immutable: new versions, old ones unchanged
ImmutableList<string> v1 = ["a", "b"];
ImmutableList<string> v2 = v1.Add("c");
Console.WriteLine($"{v1.Count} {v2.Count}"); // 2 3
// 3. ImmutableArray: array-speed reads, build with a builder
var builder = ImmutableArray.CreateBuilder<int>(initialCapacity: 1000);
for (int i = 0; i < 1000; i++) builder.Add(i * i);
ImmutableArray<int> squares = builder.MoveToImmutable(); // no copy
ReadOnlySpan<int> span = squares.AsSpan();
ImmutableArray<int> notSet = default;
Console.WriteLine(notSet.IsDefault); // true; notSet.Length would throw
// 4. Lock-free shared state
private ImmutableDictionary<string, int> _limits = ImmutableDictionary<string, int>.Empty;
public void SetLimit(string key, int value) =>
ImmutableInterlocked.Update(ref _limits, d => d.SetItem(key, value));
public int GetLimit(string key) => _limits.GetValueOrDefault(key); // no lock
// 5. Read-optimised snapshot rebuilt on refresh
private FrozenDictionary<string, bool> _flags = FrozenDictionary<string, bool>.Empty;
public void Refresh(IDictionary<string, bool> latest) =>
Volatile.Write(ref _flags, latest.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
What happens: items.Add(x); on an ImmutableList<T> or ImmutableArray<T>.
Why it is wrong: the method returns a new collection; the original is unchanged, so the update is lost.
Do instead: assign the result (items = items.Add(x);), and rely on CA1806-style analysers.
ImmutableArray<T> in a loopWhat happens: arr = arr.Add(item) for thousands of items.
Why it is wrong: each add copies the whole array — O(n²).
Do instead: use ImmutableArray.CreateBuilder and MoveToImmutable, or build a List<T> and convert once.
IReadOnlyList<T> as immutableWhat happens: returning an internal List<T> as IReadOnlyList<T> and assuming it's safe to share across threads.
Why it is wrong: the list can still be changed by its owner (or by a cast), and readers see changes mid-enumeration.
Do instead: return a snapshot or an immutable/frozen collection.
ReadOnlyCollection<T> is a snapshotWhat happens: AsReadOnly() used to "freeze" data before handing it out.
Why it is wrong: it's a live wrapper; later changes to the source list appear.
Do instead: copy (ToArray()), or use ImmutableArray<T>.
ImmutableList<T> for read-heavy dataWhat happens: ImmutableList<T> chosen for a lookup table read on every request.
Why it is wrong: tree-based reads and iteration are much slower than arrays.
Do instead: use ImmutableArray<T> or frozen collections for read-heavy data.
ImmutableArray<T> valuesWhat happens: an uninitialised ImmutableArray<T> field or property is enumerated.
Why it is wrong: the default value wraps a null array and throws.
Do instead: initialise to [] and check IsDefaultOrEmpty at boundaries.
ImmutableArray<T>What happens: two ImmutableArray<T> with the same items compared with ==, or used in records expecting value equality.
Why it is wrong: equality compares the underlying array reference.
Do instead: use SequenceEqual, or override record equality.
What happens: ToFrozenDictionary() called per request or per small update.
Why it is wrong: construction is expensive; the benefit needs many reads per build.
Do instead: build frozen collections at start-up or on infrequent refreshes.
What happens: _cache = _cache.Add(key, value); from multiple threads.
Why it is wrong: two writers can read the same version and one update is lost.
Do instead: use ImmutableInterlocked.Update/AddOrUpdate, or a lock around the swap.
What happens: an ImmutableList<Order> of mutable Order objects is shared, and someone changes an order's status.
Why it is wrong: the collection is immutable, but its elements aren't — shared state still changes.
Do instead: use immutable element types (records with init) for truly immutable snapshots.
Seniors pick the level of protection the API actually needs and pay only for that.
| Goal | Cheapest correct choice | Cost |
|---|---|---|
| Callers should not modify | return IReadOnlyList<T> |
none |
| Callers must not see later changes | ToImmutableArray() snapshot |
one copy |
| Many versions over time | ImmutableList/ImmutableDictionary |
slower reads |
| Hot read-only lookup | FrozenDictionary |
slow build |
| Shared mutable state, lock-free reads | immutable + ImmutableInterlocked |
allocation per write |
The senior insight is that immutability moves cost from coordination (locks) to allocation (new versions). That is a great trade for read-heavy, rarely written state such as configuration and routing tables, and a poor one for high-frequency writes, where ConcurrentDictionary is usually better.
System.Collections.Concurrent types are safe for many threads at once. ConcurrentDictionary uses lock-free reads and fine-grained (striped) locks for writes. Each single method is atomic, but check-then-act across two calls is not, so use GetOrAdd, AddOrUpdate, TryUpdate or TryRemove. Caveat: the value factory in GetOrAdd/AddOrUpdate can run more than once under contention (only one result is stored), so wrap expensive or side-effecting work in Lazy<T>. Count, IsEmpty and ToArray take every lock, so avoid them in hot paths. ConcurrentQueue/ConcurrentStack are lock-free FIFO/LIFO. ConcurrentBag is unordered, optimised for the same thread adding and taking. BlockingCollection adds bounding and blocking Take but blocks threads; for async producer/consumer use Channel<T>.
Choose by access pattern, then mutability, then concurrency. Index access or iteration: List<T>/array. Lookup by key: Dictionary. Uniqueness or membership: HashSet. Order of processing: Queue/Stack/PriorityQueue. Sorted keys: SortedList (read-mostly) or SortedDictionary (write-heavy). Shared across threads: ConcurrentDictionary/Channel<T>, or an immutable/frozen snapshot if writes are rare. Built once and read forever: FrozenDictionary/FrozenSet/ImmutableArray. For APIs, accept the least specific type you need (IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, or ReadOnlySpan<T>) and return a specific read-only type (IReadOnlyList<T>, IReadOnlyDictionary, or an immutable type), not a mutable List<T>.
How to tell it (STAR)
**Situation:** At [company] we had rare reports that a feature switched off for a single request, and occasional 'collection was modified' errors in the logs. **Task:** I took the ticket to find the cause, since it was affecting [a paid feature]. **Action:** I found the flag refresher cleared and refilled the same `Dictionary` that request threads were reading. Any request in that window saw missing flags. Rather than add locks to a hot path, I made the refresher build a new `FrozenDictionary` and swap the reference atomically, so readers always get a complete, unchanging snapshot. I wrote a stress test that reads constantly during refreshes to prove it. **Result:** The intermittent reports and exceptions stopped, flag lookups got [slightly] faster, and the 'publish a snapshot' pattern became our standard for shared configuration.
Last updated 30 Oct 2026.