AlgoMaster Logo

List<T>

High Priority29 min readUpdated June 6, 2026

List<T> is the generic, resizable, indexed collection used by most C# programs. Arrays are fixed in size and IEnumerable<T> only describes iteration; List<T> is the container that adds, removes, sorts, searches, and indexes elements without requiring manual memory management. This lesson covers how it's built, how it grows, the common methods, and the pitfalls around it.

Constructing a List

There are four ways to construct a List<T>, and each one says something different about how the list will be used.

The empty list starts with Capacity=0, not 4 or 16 as some other languages do. The first Add allocates a small backing array (capacity 4 on .NET 8), and after that the array doubles each time it fills up. The pre-sized list reserves space for 100 elements up front but still reports Count=0 because no elements have been added yet. The list built from an array gets capacity exactly equal to the source length, and the collection initializer behaves the same way as adding each element through Add, ending up with capacity equal to whatever the growth strategy produced after the last insert.

The fourth form, the collection initializer, is the most common. It's syntactic sugar for new List<string>() followed by a series of Add calls. The compiler unrolls it, so this:

is identical to this:

The collection initializer requires that the type has an accessible Add method that matches the argument types. List<T> has one, so the compiler accepts it.

C# 12 introduced a fifth form, collection expressions, which uses square brackets:

Collection expressions work for any collection type the compiler knows how to build, not just List<T>. The _Collection Expressions_ lesson covers them in depth. For the rest of this lesson, the older initializer syntax keeps the examples uniform with existing C# code.

Constructing with a known capacity (new List<T>(capacity: n)) avoids resize allocations as the list fills. When the approximate size is known, prefer this over the empty constructor.

Count vs Capacity

List<T> keeps two numbers that describe its state, and they are commonly confused.

PropertyWhat it tells you
CountHow many elements the list currently holds. This is what you iterate over and what list[i] is bounded by.
CapacityHow many elements the backing array can hold before it needs to be resized. Always >= Count.

Count is the public-facing number. When you write for (int i = 0; i < list.Count; i++), you're iterating over the elements that actually exist. Capacity is an implementation detail of List<T>: it's the size of the private array the list uses internally, and it's only larger than Count because the list reserves slack space to avoid resizing on every Add.

The list has room for 8 elements in its backing array, but only 3 have been added. Indexing past Count - 1 throws ArgumentOutOfRangeException regardless of what Capacity is. From a user's perspective, the slots between index 3 and 7 don't exist, even though the backing array has memory there.

This matters in two ways. First, when iterating, always bound by Count, never by Capacity. Second, to check whether a list is empty, use Count == 0 (or the Count is 0 pattern, or LINQ's Any()), never Capacity == 0.

After adding 3 elements to a list with capacity 8, the state looks like this.

The first three slots hold the added elements. The remaining five exist in memory (the backing array was allocated with length 8) but they aren't part of the list's logical contents. Count is the boundary between "real" and "unused." When a fourth element is added, it goes into slot [3], Count becomes 4, and Capacity stays at 8.

How Add Grows the List

When Count reaches Capacity and you call Add, the list has run out of room in its backing array. It can't grow the array in place (arrays are fixed in size), so it has to allocate a bigger one, copy the existing elements over, and discard the old one. The growth strategy is doubling: the new capacity is max(4, oldCapacity * 2).

The first Add allocates an array of length 4. Adds 2 through 4 use the slack space already in that array. Add 5 finds Count == Capacity, so the list allocates a new array of length 8, copies the four existing elements over, and stores the new element in slot [4]. Add 9 triggers another doubling: length 16. The pattern continues: 32, 64, 128, and so on.

The doubling strategy keeps Add cheap on average. Adding n items requires at most about 2n total writes (the copies during resizes plus the writes for each Add), so each individual Add runs in amortized O(1) time. Any one specific Add might be slow (the one that triggers a resize on a list with a million elements has to copy a million items), but over many adds, the average cost per add is constant.

The trigger is Count == Capacity on the next Add. A new array of double the length is allocated, the existing four elements are copied over by index, the new element goes into the slot at the original Count, and the old array is left for the garbage collector. The new Capacity is 8 even though only 5 slots are used.

Each resize copies every existing element. For a million-item insert, calling EnsureCapacity(1_000_000) once is much faster than letting the list resize about 20 times along the way. EnsureCapacity was added in .NET 6 and returns the resulting capacity, which can be larger than requested.

The growth strategy is max(4, oldCapacity * 2), with one subtlety: on a 32-bit process the maximum array length is around 2 billion elements, and on a 64-bit process with large-object support arrays can hold more. If the doubling would overflow, the list grows to the platform maximum and then Add throws OutOfMemoryException if you try to push past it.

Adding More Than One Element at a Time

Add puts a single element at the end. AddRange does the same for a whole sequence, which is faster than looping and more readable.

AddRange accepts any IEnumerable<T>: arrays, other lists, LINQ query results, the output of yield return, anything that produces a sequence of the right element type. Internally, when the source implements ICollection<T> (which arrays and lists do), AddRange reads its Count, ensures the list's backing array has room for that many extra elements, then bulk-copies. When the source is a general IEnumerable<T>, it falls back to iterating and adding one at a time.

Insert and InsertRange do the same thing but at a specific position. The index can be anything from 0 (insert at the front) to Count (insert at the end, equivalent to Add or AddRange).

Insert requires a shift. To insert at index 0, every existing element has to move one slot to the right inside the backing array. That's O(n) work, where n is the number of elements after the insertion point. Inserting at the front of a million-element list copies a million entries.

Each existing element gets copied one slot to the right, in reverse order so nothing gets overwritten. Then the new element drops into the freed-up slot at index 0. The total work is proportional to the number of elements that had to move, which is Count - index. Inserting at the end (index == Count) shifts nothing and runs in amortized O(1), exactly like Add.

Insert(0, x) and InsertRange(0, range) are O(n). For frequent front-insertion, a different data structure fits better. LinkedList<T> supports O(1) prepend, and Queue<T> allows enqueue at one end and dequeue at the other.

Reading and Writing By Index

Indexed access is the common operation on a list and is where List<T> outperforms linked structures: reads and writes at any index are O(1). The runtime computes the address as baseAddress + index * elementSize and reads or writes that slot directly.

The indexer accepts any int from 0 to Count - 1. Anything else (negative, or >= Count) throws ArgumentOutOfRangeException. The exception is raised by the list itself before it touches the backing array, producing a clean error instead of a buffer overrun.

Since C# 8, the index-from-end operator (^) and ranges (..) are also available. The from-end operator is shorthand for list[list.Count - n], so list[^1] is the last element, list[^2] is the second-to-last, and so on.

Some subtleties about ranges on lists. The range 1..4 is half-open, like array slicing in many languages: index 1 is included, index 4 is not. So products[1..4] returns indices 1, 2, and 3. The from-end shorthand works the same way: ^2.. means "from the second-to-last to the end," producing indices 3 and 4.

A range slice on List<T> returns a new `List<T>`, not a view onto the original. The new list has its own backing array, and modifying it doesn't affect the source.

For a non-copying view, use Span<T> or ArraySegment<T> over the underlying array. For most application code, the copying slice is sufficient, and the cost is rarely noticeable.

list[range] allocates a new list and copies the slice elements. Avoid this inside a hot loop where the same slice is created repeatedly; compute it once or iterate the indices directly.

Removing Elements

List<T> offers several ways to remove elements, each with its own semantics around what's removed and how the indices change afterward.

Remove(item) finds the first element equal to item (using EqualityComparer<T>.Default), removes it, and returns true. If nothing matches, it returns false and leaves the list unchanged. The second Mug stays: Remove only removes one occurrence per call.

RemoveAt(index) removes the element at that exact position, shifting everything after it one slot to the left. It's the inverse of Insert. The index must be in [0, Count - 1]; anything else throws ArgumentOutOfRangeException.

RemoveRange(index, count) removes count consecutive elements starting at index. It's the bulk version of RemoveAt, and the total cost is proportional to the number of elements after the removed range that have to shift left.

RemoveAll(predicate) removes every element for which the predicate returns true, and returns how many were removed. It runs in a single pass, scanning the list once and compacting the survivors. It's far more efficient than calling Remove in a loop, which would be O(n^2) because each call walks the list.

Clear() empties the list, setting Count to 0 and zeroing out the backing array's slots so the garbage collector can reclaim any held references. Capacity stays at 8 after the clear: the backing array isn't freed, only its contents. To shrink the backing array too, call TrimExcess afterward.

Remove and RemoveAt at index i shift Count - i - 1 elements left. Removing from the front is O(n). RemoveAll(predicate) is O(n) total, no matter how many elements match.

The shifting behavior of RemoveAt(0) mirrors the shifting from Insert(0, x) shown earlier. Both ends behave very differently: insert and remove at the end are cheap, while insert and remove at the front are expensive in proportion to list size.

Searching: Contains, IndexOf, Find

To check whether an item is in the list or to locate it, several methods are available, differing in what they return.

Contains returns a bool and walks the list comparing each element to the target with EqualityComparer<T>.Default. For reference types, the default comparer calls Equals on the elements; for value types it calls the type's Equals override or, failing that, performs a field-by-field comparison.

IndexOf returns the zero-based index of the first match, or -1 if nothing matches. LastIndexOf does the same but scans from the end. Both have overloads that take a starting index and a count, so you can search a subrange.

All three are O(n) for List<T>: the list is unordered as far as the search is concerned, so finding an element requires scanning until a match or the end. For O(1) membership checks, use HashSet<T> (the _HashSet&lt;T&gt;_ lesson). For O(log n) ordered lookup, use SortedSet<T> or SortedDictionary<TKey, TValue> (the _SortedSet&lt;T&gt;_ and _SortedDictionary & SortedList_ lessons).

Find, FindAll, FindIndex, and FindLast take a predicate instead of a value and express "first element that matches this condition."

Find returns the first element that satisfies the predicate, or the type's default value if nothing matches. For reference types that's null, which is why the variable is declared as Product?. FindAll returns a new list (possibly empty) of every matching element. FindIndex returns the index of the first match (or -1). FindLast and FindLastIndex do the same but scan from the end.

The predicate is a Predicate<T>, a delegate that takes a T and returns a bool. It's typically written as a lambda, but a method group or a stored delegate also works.

All the Find* methods are O(n). They scan the list. The predicate is called for each element until it returns true (for Find, FindIndex) or for every element (for FindAll).

Sorting and Reversing

Sort arranges the list in place using a comparison rule. The default sort uses Comparer<T>.Default, which works for any type that implements IComparable<T>. Built-in types like int, string, and DateTime implement it, so sorting a list of them works without extra setup.

Sort with no arguments uses the type's default ordering. Sort(Comparison<T>) takes a delegate of the form (a, b) => int, returning a negative number when a should come before b, zero when they're equal in order, and a positive number when b should come first. The lambda (a, b) => b.CompareTo(a) reverses the default ordering by flipping the operands.

Sort(IComparer<T>) is the third overload, useful when the ordering rule is non-trivial enough to deserve its own type, or for reuse across multiple sorts. The _IComparer & IEqualityComparer_ lesson covers IComparer<T> in depth (the contract, equality consistency, and how IComparable<T> differs). A short example follows, with the deep treatment left for that lesson.

The IComparer<Product> implementation defines what "in order" means for products: ordered by price ascending. List<Product>.Sort calls Compare(a, b) repeatedly during the sort to decide the order. The full mechanics of writing a correct Compare (handling nulls, ensuring transitivity, the contract with Equals) belong in the _IComparer & IEqualityComparer_ lesson, so the version above is intentionally short.

Sort is implemented as an introspective sort (introsort) in .NET: a hybrid of quicksort, heapsort, and insertion sort that runs in O(n log n) on average and worst case. The sort is not stable: equal elements may swap relative positions during the sort. For stability, use LINQ's OrderBy, which is stable (and returns a new sequence rather than sorting in place).

Reverse flips the list in place. It's O(n), but it doesn't compare anything: it walks from both ends toward the middle, swapping pairs. Use it to invert the current order, regardless of what that order represents.

Sort is O(n log n). Reverse is O(n). Both operate in place and don't allocate a new backing array. LINQ's OrderBy(...).ToList() allocates a new list and is stable, in exchange for the allocation.

ForEach, ToArray, ToList, and AsReadOnly

A few helpers round out the list's API. ForEach(Action<T>) runs a delegate on each element, in order. It's an older method (predates LINQ) and is rarely used now, since foreach is clearer and supports break, continue, and await. It still appears in existing code.

ToArray allocates a new T[] of length Count and copies the elements over. Use it to pass data to an API that takes an array, or to take a snapshot that won't change when the list does later.

ToList is a LINQ extension defined on IEnumerable<T>, so it exists for every sequence. When called on a List<T>, it does not return the same instance: it allocates a new list and copies the elements. ToList doesn't know it's looking at a List<T>; it only sees the IEnumerable<T> interface and produces a fresh list to be safe. For a copy, that's the correct behavior. To reuse the existing list, don't call ToList on it.

AsReadOnly returns a ReadOnlyCollection<T>, which wraps the list and exposes only read methods. The wrapper isn't a copy: changes to the underlying list are visible through the wrapper, but callers holding the wrapper can't mutate the list themselves. This is the typical way to hand a list to consumers without allowing modification.

TrimExcess and EnsureCapacity

The backing array grows by doubling, so after a series of adds the capacity can be substantially larger than the count. For a long-lived list that won't grow further, TrimExcess shrinks the backing array to fit. The method only reallocates if the unused space is significant (more than 10% by default), so calling it on a list that's already a tight fit is essentially free.

TrimExcess allocated a new array of length 5 and copied the five elements, dropping the old 1000-slot array. That's a copy, so avoid it inside a loop or repeated calls. Apply it after a long-lived list is finished growing.

EnsureCapacity(n), added in .NET 6, grows the backing array to at least n if it isn't already that big. This is a single allocation, not the series of doublings produced by many Add calls. To add 10,000 items, calling EnsureCapacity(10_000) before the loop turns the cumulative resize cost from about 20 separate allocations plus 20,000 copies into one allocation, zero copies.

Call EnsureCapacity (or pass capacity to the constructor) when the final size is known in advance and large. Doubling is fine for small lists, but for hundreds of thousands of items, eliminating the intermediate resizes is a significant gain.

Modifying During foreach

Modifying a list while iterating it with foreach throws InvalidOperationException with the message "Collection was modified; enumeration operation may not execute." This is a common runtime error with List<T>.

The exception isn't thrown from Remove. The mutation succeeds. It's thrown from the next MoveNext call on the enumerator, which detects that the list has changed since iteration started. The list's enumerator keeps a snapshot of the list's internal version number, and every mutating operation (Add, Insert, Remove, RemoveAt, Clear, the indexer setter) increments that number. When the enumerator detects a mismatch, it throws.

The fix depends on the goal. To remove all elements matching a condition, use RemoveAll:

For more complex logic, iterate backward by index so removals don't disturb the indices of unvisited elements:

Iterating backward works because RemoveAt(i) only shifts elements with indices greater than i. Since those have already been visited (moving from the end toward the start), the shift doesn't affect anything still to come.

A third option, useful for filtering rather than mutating, is to build a new list with LINQ:

This creates a new list and leaves the original alone, which sidesteps the problem entirely. The trade-off is the allocation, but for moderate-size lists it's the standard approach.

The "modify during foreach" rule has one important exception: setting an existing element through the indexer (list[i] = newValue) does not count as a structural modification and doesn't increment the version. So overwriting elements during a for loop is fine. Only operations that change Count (add, insert, remove, clear) trip the enumerator.

Reference vs Value Semantics

List<T> is a reference type. The variable holds a reference to the list object on the heap, and assigning the variable to another variable copies the reference, not the elements.

Adding through alias is visible through original because both names refer to the same list. This affects code that passes a list into a method expecting read-only access. Unless a ReadOnlyCollection<T> or IReadOnlyList<T> is passed, the receiver can mutate the same list the caller holds.

For an independent copy, construct a new list from the original:

This is a shallow copy. The new list has its own backing array and its own Count, so adds and removes don't affect the original. But for reference-type elements, both lists hold references to the same underlying objects. Mutating the elements (not the list) is visible through both lists.

snapshot.Add only affects snapshot, since the two lists have separate backing arrays. But snapshot[0].Owner = "Alicia" mutates the same Cart object that's at index 0 of original, and the change is visible through both lists. For a deep copy, clone each element while building the new list. List<T> doesn't do this; it doesn't know how to clone arbitrary types.

For value-type elements (int, double, struct), each list slot holds a copy of the value. Mutating an element of one list never affects another. The reference-vs-value distinction is a property of T, not of List<T> itself.

A shallow copy via new List<T>(original) allocates a new backing array and copies Count element references. It's O(n) but does not call any element constructors or clone any reference-type elements.

Performance Summary

Quick reference for List<T> operation costs. Pick a data structure by what's common in the access pattern.

OperationCostNotes
list[i] read or writeO(1)Direct array indexing.
Add(item)Amortized O(1)Doubling on resize keeps the average constant.
AddRange(seq)O(k) where k = seq lengthBulk copy when source is ICollection<T>.
Insert(i, item)O(n - i)Shifts elements after i to the right.
Insert(0, item)O(n)Worst case of Insert.
RemoveAt(i)O(n - i)Shifts elements after i to the left.
Remove(item)O(n)Searches first, then removes.
RemoveAll(pred)O(n)Single pass, compacts in place.
Contains(item)O(n)Linear scan.
IndexOf(item)O(n)Linear scan.
Find(pred)O(n)Linear scan, returns first match.
Sort()O(n log n)Introspective sort, in place, not stable.
Reverse()O(n)In place.
Clear()O(n)Zeros the slots so the GC can reclaim references.
ToArray()O(n)Allocates a new array.
Capacity get/setO(1) get; O(n) set if it changesSetting reallocates the backing array.

When most operations are at the end and fast indexed access matters, List<T> is the fit. For fast insert and remove at both ends, use Deque-style structures or LinkedList<T> (the _LinkedList&lt;T&gt;_ lesson). For fast membership checks, use HashSet<T> (the _HashSet&lt;T&gt;_ lesson). For ordered iteration with fast lookup, use SortedSet<T> or SortedDictionary<TKey, TValue> (the _SortedDictionary & SortedList_ and _SortedSet&lt;T&gt;_ lessons).

Quiz

List Quiz

9 quizzes