Internals of `List<T>`: backing array, doubling growth, operation costs, version checks, and modern performance APIs.
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).
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.
Count = number of elements in use; Capacity = length of the backing array.List<T>() starts with an empty array (capacity 0). The first Add allocates capacity 4.TrimExcess() shrinks if more than 10% is unused.ints or > ~10,600 references).| 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.
foreach throwsEvery 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.
EnsureCapacityvar 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.
CollectionsMarshal.AsSpanSpan<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
Count elements) of the backing array — no copy.span[i].Field = ...), vectorised APIs, and fastest loops.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.CollectionsMarshal.SetCount(list, n) to set Count directly (e.g. before filling via the span).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.
Checks whether the candidate understands the data structure behind the most used collection in .NET.
Insert/Remove are O(n), explains the version check, mentions RemoveAll, pre-sizing and CollectionsMarshal.AsSpan with its caveat.List<T> is a linked list, believes Remove is O(1), or has never seen "Collection was modified".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.
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.
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.
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.
ForEach, LINQ and ConvertAlllist.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.Count(), ElementAt(), Last() detect IList<T> and use O(1) paths.List<T> is safe for multiple concurrent readers if nobody writes. Any concurrent write can:
IndexOutOfRangeException during a resize race._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.
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.Capacity after each Add from 0 to 100 items.[MemoryDiagnoser].Insert(0, x) vs Add(x) + Reverse() for 100,000 items.CollectionsMarshal.AsSpan span, add items until the list resizes, write through the span, and observe the lost update.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; }
foreachWhat 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.
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.
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.
Contains on large lists in loopsWhat 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.
List<T> across threads with writesWhat 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.
CollectionsMarshal.AsSpanWhat 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.
Clear or Remove to release memoryWhat 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.
List<T> from public APIsWhat 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).
ToList() repeatedlyWhat 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.
Sort is stableWhat 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.
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.
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.
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.
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.
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.
Last updated 30 Oct 2026.