📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are immutable collections in .NET? Compare ImmutableArray<T>, ImmutableList<T> and ImmutableDictionary<TKey,TValue>, explain builders and structural sharing, and contrast them with ReadOnlyCollection<T>, IReadOnlyList<T> and FrozenDictionary.

System.Collections.Immutable types, their internal structures and costs, builders, and how they differ from read-only views and frozen collections.

Mid-Level Theory Advanced collections frozen-collections immutable immutablearray thread-safety

🎯 Key points

  • Immutable collections never change; operations like `Add` return a new collection
  • Read-only interfaces and `ReadOnlyCollection<T>` are views; the underlying data can still change
  • `ImmutableArray<T>` is a struct over an array: fast reads, O(n) copy on every change
  • `ImmutableList<T>` and `ImmutableDictionary` are trees with structural sharing: O(log n) updates, slower reads
  • Use builders (`ToBuilder`, `CreateBuilder`, `MoveToImmutable`) for many changes
  • `default(ImmutableArray<T>)` is unusable; check `IsDefault` or initialise to empty
  • `ImmutableInterlocked.Update` gives lock-free atomic updates of a shared immutable field
  • `FrozenDictionary`/`FrozenSet` (.NET 8+) are read-optimised and have no modified-copy operations

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. The main immutable types

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.

2. Structural sharing

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.

3. Builders

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.

4. Read-only views are not immutable

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".

5. Frozen collections vs immutable dictionaries

  • 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.

6. Thread safety pattern: atomic snapshot swap

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.

7. A production example

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.

🔍 What the interviewer is checking

Checks whether the candidate understands the difference between read-only and immutable and knows the performance profile of each immutable type.

  • Green flags: distinguishes view versus snapshot, knows ImmutableArray copies on write while ImmutableList shares structure, uses builders, mentions ImmutableInterlocked and frozen collections.
  • Red flags: thinks ReadOnlyCollection is immutable, adds to immutable collections in loops, or picks ImmutableList for hot reads.

📚 Learn in depth

1. ImmutableArray<T> internals

public readonly struct ImmutableArray<T> : IReadOnlyList<T>, IImmutableList<T>, IEquatable<ImmutableArray<T>>
{
    internal readonly T[]? array;
}
  • A struct with a single field: passing it around costs the same as passing an array reference.
  • Indexing and 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.
  • Equality (==) 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.

2. ImmutableList<T> as an AVL tree

Each 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.

3. ImmutableDictionary internals

It'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.

4. Builders

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.

5. ImmutableInterlocked

ImmutableInterlocked.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.

6. Frozen collections internals

ToFrozenDictionary/ToFrozenSet choose among many specialised implementations based on the keys:

  • Small collections (≤ ~10): linear scans over arrays can be fastest.
  • Integer keys: dense-range or specialised hashing.
  • String keys: analyse lengths and character positions to find a subset of characters that uniquely discriminates keys, compute hashes over just that substring, and optionally reject by length first; case-insensitive ASCII-specific variants.

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.

7. Choosing

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)

Try it yourself

  1. Benchmark lookups in Dictionary, ImmutableDictionary and FrozenDictionary with 1,000 string keys.
  2. Benchmark adding 10,000 items one at a time to ImmutableArray (O(n²)), ImmutableList, and an ImmutableArray.Builder.
  3. Wrap a list with AsReadOnly(), modify the original list, and observe the wrapper.
  4. Implement the atomic snapshot swap with ImmutableInterlocked and hammer it from multiple threads.

💻 Code

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));

⚠ Common mistakes

1. Ignoring the return value of immutable "mutations"

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.

2. Adding to ImmutableArray<T> in a loop

What 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.

3. Treating IReadOnlyList<T> as immutable

What 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.

4. Thinking ReadOnlyCollection<T> is a snapshot

What 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>.

5. Using ImmutableList<T> for read-heavy data

What 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.

6. Default ImmutableArray<T> values

What 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.

7. Expecting content equality from 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.

8. Rebuilding frozen collections frequently

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.

9. Non-atomic read-modify-write of shared immutable references

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.

10. Mutating elements inside immutable collections

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate compares immutable snapshots with concurrent collections Why: Checks read/write ratio reasoning
    How to answer

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

  2. When: Candidate mentions returning read-only types from APIs Why: Checks API surface design
    How to answer

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

🛠 Real-world problems: how you handle them

Feature flags briefly disappeared during a background refresh
Situation
A web API read feature flags from a shared `Dictionary<string, bool>` that a background service refreshed every [30 seconds] by clearing it and adding the new values.
Signals
Rare, unreproducible reports of features switching off for a single request; occasional `InvalidOperationException` (collection modified) in logs from requests that enumerated flags.
Investigation
Reviewed the refresher and found `Clear()` followed by adds on the same instance that request threads were reading without a lock. Requests that hit the window saw missing keys; enumerations threw.
Resolution
Changed the refresher to build a new `FrozenDictionary` from the latest values and publish it with `Volatile.Write`. Readers capture the reference once per request and always see a complete snapshot.
Prevention
Added a concurrency test that hammers reads during refreshes, and a guideline that shared lookup state is published as an immutable snapshot rather than mutated in place.

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.

↩ Usually asked after

More in Collections

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me