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

📚 Interview question

How does List<T> work internally? Explain capacity and growth, the cost of Add, Insert and Remove, why modifying a list during foreach throws, and how CollectionsMarshal.AsSpan and EnsureCapacity help in hot paths.

Internals of `List<T>`: backing array, doubling growth, operation costs, version checks, and modern performance APIs.

Mid-Level Theory Intermediate collections collectionsmarshal enumeration list performance

🎯 Key points

  • `List<T>` wraps a `T[]` with `_size` and `_version` fields
  • Capacity starts at 4 and doubles, so `Add` is amortised O(1)
  • `Insert`, `RemoveAt`, `Remove`, `Contains` are O(n); index access is O(1)
  • Changing the list during `foreach` bumps `_version` and throws `InvalidOperationException`
  • Use `RemoveAll` for O(n) bulk removal instead of removing in a loop
  • Pre-size with the constructor or `EnsureCapacity` to avoid growth copies and LOH churn
  • `CollectionsMarshal.AsSpan` gives zero-copy access but becomes stale after the list grows
  • The indexer returns a copy, so mutable struct elements cannot be changed through it

⏱ 30-second answer

List<T> wraps a T[] plus a _size count and a _version counter. When the array is full, Add allocates a new array twice as large (starting at 4) and copies, so Add is amortised O(1). Insert and Remove shift elements, so they are O(n). Every change increments _version; the enumerator checks it on each MoveNext and throws InvalidOperationException if the list changed. In hot paths, pre-size with the constructor or EnsureCapacity, and use CollectionsMarshal.AsSpan to work on the backing array without copying (but never add or remove while holding that span).

🗣 Full answer (senior)

How I would answer this in an interview

List<T> is a dynamic array: a T[] backing store plus a Count. Capacity grows geometrically, so Add is amortised O(1), but Insert and Remove in the middle are O(n) because elements shift. It tracks a version number to detect modification during enumeration. For hot paths, EnsureCapacity avoids regrowth and CollectionsMarshal.AsSpan exposes the backing array as a span.

1. Capacity and growth

  • Count = number of elements in use; Capacity = length of the backing array.
  • A new List<T>() starts with an empty array (capacity 0). The first Add allocates capacity 4.
  • When full, it allocates a new array of double the size, copies the elements, and drops the old array.
  • Growth sequence: 4, 8, 16, 32, ... For 1,000,000 elements, ~18 reallocations and ~2 million element copies in total — still O(n) overall.
  • Memory waste: up to 50% spare capacity just after a resize. TrimExcess() shrinks if more than 10% is unused.
  • Large lists' backing arrays go on the Large Object Heap once they exceed ~85 KB (e.g. > ~21,000 ints or > ~10,600 references).

2. Cost of operations

Operation Cost Why
list[i] get/set O(1) Array index (bounds-checked)
Add Amortised O(1); O(n) on resize Append; occasional copy
AddRange(ICollection<T>) O(n + m) Resizes once using Count, then copies
Insert(i, x) O(n − i) Shifts later elements right (Array.Copy)
RemoveAt(i) O(n − i) Shifts later elements left
RemoveAt(Count - 1) O(1) Nothing to shift
Remove(x) O(n) Linear search + shift
Contains, IndexOf O(n) Linear search (vectorised for primitives)
RemoveAll(pred) O(n) Single compaction pass
Sort O(n log n) Introsort, unstable
BinarySearch O(log n) Requires sorted list
Clear O(n) for reference types (clears references), O(1) for unmanaged Must null references for GC

Anti-pattern: Insert(0, x) or RemoveAt(0) in a loop — O(n²). Use Queue<T>, LinkedList<T>, or build in reverse.

3. Why modifying during foreach throws

Every mutating method (Add, Insert, Remove, Clear, Sort, and the indexer setter in older versions) increments _version. The struct enumerator stores the version when created and checks it on each MoveNext(); if it differs, it throws InvalidOperationException: Collection was modified; enumeration operation may not execute. This catches bugs like skipping elements or reading past the end after a removal.

Safe alternatives: RemoveAll(predicate), iterating backwards with for, iterating over a snapshot (ToArray()), or building a new list.

It is not a thread-safety mechanism: concurrent writers can corrupt a List<T> without any exception.

4. EnsureCapacity

var results = new List<OrderDto>(orders.Count);   // pre-size in the constructor, or
results.EnsureCapacity(results.Count + incoming.Count);  // .NET 6+

When you know (or can estimate) the final size, pre-sizing avoids all intermediate arrays and copies — particularly important when the list will be large enough to hit the LOH repeatedly during growth.

5. CollectionsMarshal.AsSpan

Span<decimal> amounts = CollectionsMarshal.AsSpan(list);
for (int i = 0; i < amounts.Length; i++) amounts[i] *= 1.1m;   // no version checks, bounds checks eliminated
decimal total = TensorPrimitives.Sum(...);                      // for float/double spans
  • Gives a span over the used portion (Count elements) of the backing array — no copy.
  • Enables in-place updates of struct elements (span[i].Field = ...), vectorised APIs, and fastest loops.
  • Danger: if the list is resized (any Add that triggers growth) while you hold the span, the span still points to the old array — writes are silently lost. No version check protects you. Don't modify the list's size while using the span.
  • .NET 8 added CollectionsMarshal.SetCount(list, n) to set Count directly (e.g. before filling via the span).

6. A production example

A feed-generation job built product lists with new List<Product>() and Add for ~300,000 items, then inserted promotional items with Insert(0, ...) in a loop. A trace showed significant time in Array.Copy and multiple LOH allocations as the list doubled through 128K, 256K and 512K capacities. Pre-sizing the list from the query count, appending promotions at the end and ordering at the database level eliminated the regrowth and the O(n²) inserts; the job went from minutes to seconds and Gen2 collections during the job disappeared.

🔍 What the interviewer is checking

Checks whether the candidate understands the data structure behind the most used collection in .NET.

  • Green flags: explains doubling growth and amortised O(1), knows Insert/Remove are O(n), explains the version check, mentions RemoveAll, pre-sizing and CollectionsMarshal.AsSpan with its caveat.
  • Red flags: thinks List<T> is a linked list, believes Remove is O(1), or has never seen "Collection was modified".

📚 Learn in depth

1. The implementation (simplified)

public class List<T> : IList<T>, IReadOnlyList<T>, IList
{
    internal T[] _items;
    internal int _size;
    internal int _version;

    public void Add(T item)
    {
        _version++;
        T[] array = _items;
        int size = _size;
        if ((uint)size < (uint)array.Length) { _size = size + 1; array[size] = item; }
        else AddWithResize(item);           // cold path, not inlined
    }

    private void Grow(int capacity)
    {
        int newCapacity = _items.Length == 0 ? 4 : 2 * _items.Length;
        if ((uint)newCapacity > Array.MaxLength) newCapacity = Array.MaxLength;
        if (newCapacity < capacity) newCapacity = capacity;
        Capacity = newCapacity;             // allocates and Array.Copy
    }
}

The hot Add path is small enough to inline; resizing is moved to a separate method so the common case stays fast.

2. Amortised analysis

With doubling, the total copy work to reach n elements is 4 + 8 + 16 + ... + n/2 < n. So n adds cost O(n) total — amortised O(1) each — even though an individual Add can cost O(n). Growth factors matter: 1.5× (used by some other runtimes) wastes less memory but copies more; .NET chose 2×.

Latency-sensitive code should care about the worst case: a single Add that triggers a copy of a 1 GB backing array can take long enough to show up as a latency spike, plus an LOH allocation.

3. Removal and GC

RemoveAt shifts elements left with Array.Copy and then sets the last slot to default if T contains references (RuntimeHelpers.IsReferenceOrContainsReferences<T>()), so removed objects can be garbage collected. Clear() does the same for the whole array. For unmanaged T (e.g. int), Clear just sets _size = 0 — O(1).

Note that removing items never shrinks capacity: a list that once held 1 million items keeps a 1-million-slot array until TrimExcess() or Capacity is set.

4. The struct enumerator and version checks

public struct Enumerator : IEnumerator<T>
{
    private readonly List<T> _list;
    private int _index;
    private readonly int _version;
    private T? _current;

    public bool MoveNext()
    {
        if (_version == _list._version && (uint)_index < (uint)_list._size)
        { _current = _list._items[_index++]; return true; }
        return MoveNextRare();   // throws if version changed
    }
}

Because the enumerator is a struct and MoveNext is small, foreach over a List<T> variable is nearly as fast as a for loop. Through IEnumerable<T>, it is boxed and calls become interface calls.

5. ForEach, LINQ and ConvertAll

  • list.ForEach(action) checks the version too and throws if the action modifies the list; it allocates a delegate (closure if capturing). A plain foreach is usually clearer.
  • ConvertAll pre-sizes the result — slightly better than Select(...).ToList() historically, though LINQ's ToList now pre-sizes for IList<T> sources too.
  • LINQ's Count(), ElementAt(), Last() detect IList<T> and use O(1) paths.

6. Thread safety

List<T> is safe for multiple concurrent readers if nobody writes. Any concurrent write can:

  • Lose additions (two threads write to the same slot).
  • Throw IndexOutOfRangeException during a resize race.
  • Corrupt _size so Count doesn't match the data.

Use a lock, ImmutableList<T>/ImmutableArray<T> snapshots, or ConcurrentQueue/ConcurrentBag/Channel<T> depending on the access pattern.

7. Interaction with EF Core and serialisation

  • EF Core materialises results into List<T> via ToListAsync, which grows as rows arrive (it doesn't know the count). For huge result sets, stream with AsAsyncEnumerable() instead.
  • System.Text.Json deserialises arrays into List<T> by default, growing as it reads.

Try it yourself

  1. Print Capacity after each Add from 0 to 100 items.
  2. Benchmark adding 1 million items with and without initial capacity, with [MemoryDiagnoser].
  3. Benchmark Insert(0, x) vs Add(x) + Reverse() for 100,000 items.
  4. Hold a CollectionsMarshal.AsSpan span, add items until the list resizes, write through the span, and observe the lost update.

💻 Code

using System.Runtime.InteropServices;

// 1. Pre-size when the count is known
var readings = new List<double>(capacity: expectedCount);

// 2. Remove safely in one pass
readings.RemoveAll(r => double.IsNaN(r));

// 3. This throws InvalidOperationException
// foreach (var r in readings)
//     if (r < 0) readings.Remove(r);

// 4. Work on the backing array without copies
Span<double> span = CollectionsMarshal.AsSpan(readings);
for (int i = 0; i < span.Length; i++)
    span[i] *= 1.1;                       // no list indexer overhead
// readings.Add(1);  // after this, 'span' would point at the old array

// 5. Mutable struct elements
List<Point> pts = [new(1, 2)];
// pts[0].X = 5;                          // CS1612: indexer returns a copy
CollectionsMarshal.AsSpan(pts)[0].X = 5;  // edits in place

// 6. Fill through a span (.NET 8+)
var buffer = new List<int>();
CollectionsMarshal.SetCount(buffer, 1000);
CollectionsMarshal.AsSpan(buffer).Fill(-1);

struct Point(int x, int y) { public int X = x; public int Y = y; }

⚠ Common mistakes

1. Removing items inside foreach

What happens: foreach (var x in list) if (...) list.Remove(x); Why it is wrong: throws InvalidOperationException (version check); a for loop forward skips elements. Do instead: RemoveAll(predicate), iterate backwards, or build a new list.

2. Inserting or removing at the front repeatedly

What happens: Insert(0, item) or RemoveAt(0) in loops. Why it is wrong: each operation shifts all elements — O(n²) total. Do instead: use Queue<T> for FIFO, Stack<T> for LIFO, append then reverse, or LinkedList<T> when you hold node references.

3. Not pre-sizing large lists

What happens: new List<T>() filled with a known large number of items. Why it is wrong: repeated reallocations, copies, LOH churn and up to 2× memory at the end. Do instead: pass capacity to the constructor or call EnsureCapacity.

4. Using Contains on large lists in loops

What happens: if (!processed.Contains(id)) inside a loop over thousands of IDs. Why it is wrong: O(n) per check — O(n²) overall. Do instead: use a HashSet<T> for membership.

5. Sharing a List<T> across threads with writes

What happens: multiple requests or tasks Add to a shared list. Why it is wrong: lost items, corrupted state and intermittent exceptions. Do instead: lock, use concurrent collections, or collect per-task results and merge.

6. Resizing while holding CollectionsMarshal.AsSpan

What happens: adding to the list while iterating or writing through the span. Why it is wrong: the span refers to the old backing array; writes are lost silently. Do instead: don't change Count while the span is in use; get a new span after modifications.

7. Expecting Clear or Remove to release memory

What happens: a long-lived list grows to millions of items, is cleared, and memory stays high. Why it is wrong: capacity is retained; the large backing array remains. Do instead: call TrimExcess() or create a new list when the large size won't recur.

8. Exposing List<T> from public APIs

What happens: public List<Order> Orders { get; }. Why it is wrong: callers can mutate internal state and you can't change the implementation later. Do instead: expose IReadOnlyList<T> or IReadOnlyCollection<T> (or a custom Collection<T> with validation).

9. Calling ToList() repeatedly

What happens: items.ToList() in multiple layers "to be safe". Why it is wrong: each call copies the whole list. Do instead: materialise once at the boundary and pass the result.

10. Assuming Sort is stable

What happens: sorting by one key and expecting ties to keep their previous order. Why it is wrong: List<T>.Sort uses introsort, which is unstable. Do instead: use OrderBy().ThenBy() or include a tie-breaker in the comparison.

🏛 Senior insight: trade-offs

Seniors know the cost model of List<T> well enough to predict when it becomes the bottleneck, and they reach for the right tool rather than micro-tuning.

Situation Choice Why
Known final size new List<T>(n) / EnsureCapacity No growth copies
Bulk filtering RemoveAll One O(n) pass
Hot numeric loop CollectionsMarshal.AsSpan Direct array access
Frequent front inserts Queue<T> / LinkedList<T> O(1) ends
Many membership checks HashSet<T> O(1) lookup
Exposing from an API IReadOnlyList<T> Callers cannot mutate

They also mention that CollectionsMarshal is deliberately an "unsafe-ish" escape hatch: it removes safety checks the list normally gives, so it belongs only in measured hot paths with a code comment explaining why.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate explains amortised O(1) Add Why: Checks the underlying dynamic array theory
    How to answer

    An array is a fixed-size block of contiguous elements of one type. Element i lives at start + i × elementSize, so indexing is O(1) and iteration is very cache-friendly. Its size cannot change.

    List<T> wraps an internal array plus a Count. When the array is full, Add allocates a new array about twice as large and copies the elements, so Add is amortised O(1) but occasionally O(n). Inserting or removing in the middle is O(n) because elements shift.

    Use an array for fixed-size data and hot paths, List<T> when the size changes, set Capacity when you know the size, and Span<T>/Memory<T> to work on slices without copying.

  2. When: Candidate notes Contains is O(n) Why: Checks they reach for a set for membership checks
    How to answer

    HashSet<T> is an unordered collection of unique values backed by a hash table, so Add, Remove and Contains are O(1) on average, versus O(n) for List<T>.Contains. Add returns false if the value is already there, which makes de-duplication simple. It has in-place set operations: UnionWith, IntersectWith, ExceptWith, SymmetricExceptWith, plus tests such as SetEquals, IsSubsetOf and Overlaps. Pass a comparer (for example StringComparer.OrdinalIgnoreCase) to define what "equal" means. Use SortedSet<T> when you need sorted order or range queries (O(log n)), and FrozenSet<T> (.NET 8+) for a set built once and read many times.

  3. When: Candidate mentions large lists and the LOH Why: Broadens to array-heavy performance tuning
    How to answer

    First I measure: dotnet-counters for allocation rate and LOH size, then an allocation profiler to see which arrays dominate. Short-lived large arrays are the worst case, because anything of 85,000 bytes or more lands on the Large Object Heap, which is only collected in gen 2. The fixes are to reuse buffers with ArrayPool<T>.Shared (always return them, use only the requested length, clear sensitive data), use spans instead of copying slices, and stream data instead of building giant arrays. For speed I keep data in contiguous arrays of structs for cache locality and use the vectorised CopyTo/Fill/TensorPrimitives APIs instead of hand-written loops. Then I prove it with BenchmarkDotNet and compare production counters.

🛠 Real-world problems: how you handle them

Batch import slowed by an O(n^2) removal loop on a List<T>
Situation
A nightly import loaded [about 500,000] customer rows into a `List<Customer>` and then removed duplicates and inactive rows before saving. The job had grown from minutes to over an hour as data grew.
Signals
CPU pinned at one core for most of the run; profiler showed most time in `Array.Copy` called from `List<T>.RemoveAt`; memory was stable, so it was not GC.
Investigation
A CPU trace with dotnet-trace showed `RemoveAt` inside a `for` loop that walked forwards and decremented the index after each removal. Every removal shifted the remaining tail, so the loop was O(n^2). Counted removals: [about 40%] of rows.
Resolution
Replaced the loop with `RemoveAll(c => !c.IsActive)` and moved de-duplication to a `HashSet<string>` of keys checked during load. Pre-sized the list from the source row count.
Prevention
Added a BenchmarkDotNet test with 1 million rows to the performance suite, a code review checklist item for removal inside loops, and job duration alerts.

How to tell it (STAR)

**Situation:** Our nightly customer import at [company] had slowly grown from [5 minutes] to over an hour, and it was close to overlapping the morning batch window. **Task:** I was asked to find out why and fix it without changing the output. **Action:** I took a CPU trace with dotnet-trace and saw most of the time in `Array.Copy` under `List<T>.RemoveAt`. The code removed inactive customers in a forward `for` loop, so every removal shifted the rest of the list, which is O(n^2). I replaced it with a single `RemoveAll` pass, moved duplicate detection into a `HashSet` during loading, and pre-sized the list. I proved the output was identical by comparing row hashes on a copy of production data. **Result:** The job dropped to [about 4 minutes]. I added a benchmark so the regression would be caught, and shared the cost table for `List<T>` operations with the team.

↩ Usually asked after

More in Collections

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me