Last Updated: May 17, 2026
List<T> is the generic, resizable, indexed collection that most C# programs reach for first. Arrays are fixed in size and IEnumerable<T> only describes iteration; List<T> is the everyday container that adds, removes, sorts, searches, and indexes elements without you having to manage memory. This lesson covers how it's built, how it grows, the methods you'll use daily, and the pitfalls that catch new C# developers off guard.
There are four ways to construct a List<T>, and each one says something different about how the list will be used.
A few things to notice. 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 one you'll see most often. 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> does, so the compiler is happy.
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 code you'll see in existing C# projects.
Cost: Constructing with a known capacity (new List<T>(capacity: n)) avoids resize allocations as you fill the list. If you know roughly how many items you'll add, prefer this over the empty constructor.
List<T> keeps two numbers that describe its state, and confusing them is one of the most common mistakes new C# developers make.
| Property | What it tells you |
|---|---|
Count | How many elements the list currently holds. This is what you iterate over and what list[i] is bounded by. |
Capacity | How 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 actually 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 you iterate, always bound by Count, never by Capacity. Second, when you're checking "is this list empty," use Count == 0 (or the Count is 0 pattern, or LINQ's Any()), never Capacity == 0.
A picture helps. After adding 3 elements to a list with capacity 8, the state looks like this.
The first three slots hold the elements you added. 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 you add a fourth element, it goes into slot [3], Count becomes 4, and Capacity stays at 8.
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.
Cost: Each resize copies every existing element. If you're going to add a million items, 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.
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).
The catch with Insert is the 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.
Cost: Insert(0, x) and InsertRange(0, range) are O(n). If you find yourself doing this a lot, you probably want a different data structure. LinkedList<T> supports O(1) prepend, and Queue<T> lets you enqueue at one end and dequeue at the other.
Indexed access is the everyday operation on a list and it's where List<T> shines compared to 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 ever touches the backing array, so you get a clean error instead of a buffer overrun.
Since C# 8, you can also use the index-from-end operator (^) and ranges (..). 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.
A few 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] gives you 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.
The important behavior is that 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.
If you need a non-copying view, look at Span<T> or use ArraySegment<T> over the underlying array. For most application code, the copying slice is what you want, and the cost is rarely noticeable.
Cost: list[range] allocates a new list and copies the slice elements. Don't do it inside a hot loop where the same slice is created repeatedly; either compute it once or iterate the indices directly.
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. Notice the second Mug stayed: 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. Notice that Capacity stays at 8 after the clear: the backing array isn't freed, only its contents. If you want to shrink the backing array too, call TrimExcess afterward.
Cost: 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) we saw 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.
When you need to know "is this item in the list" or "where is it," you have a few choices that differ 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 the only way to find an element is to scan until you hit a match or the end. If you need O(1) "is this in there" checks, HashSet<T> is the right tool (the _HashSet<T>_ lesson). If you need O(log n) ordered lookup, SortedSet<T> or SortedDictionary<TKey, TValue> (the _SortedSet<T>_ and _SortedDictionary & SortedList_ lessons) fit.
Find, FindAll, FindIndex, and FindLast take a predicate instead of a value and let you 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>, which is a delegate that takes a T and returns a bool. Most of the time you'll write it as a lambda, but you can also pass a method group or a stored delegate.
Cost: 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).
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 when you want to reuse it across multiple sorts. The _IComparer & IEqualityComparer_ lesson covers IComparer<T> in depth (the contract, equality consistency, and how IComparable<T> differs). Here's a small example so you can see the shape, 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 robust 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. If you need 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 just walks from both ends toward the middle, swapping pairs. Use it when you want the opposite of the current order, regardless of what that order represents.
Cost: 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.
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 the right tool now, since foreach is clearer and supports break, continue, and await. But it shows up in existing code, so it's worth recognizing.
ToArray allocates a new T[] of length Count and copies the elements over. It's the right move when you need to hand the data to an API that takes an array, or when you want 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. This catches people, since "I already have a list, why is it copying?" The answer is that 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. If you genuinely want a copy, that's the right behavior. If you just want the list you already have, 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 letting them modify it.
The backing array grows by doubling, so after a series of adds the capacity can be substantially larger than the count. If you're going to keep the list around and you don't expect more adds, 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 don't do it inside a loop or repeatedly. The right place is after you've finished populating a long-lived list that will sit in memory for a while.
EnsureCapacity(n) is the opposite move, added in .NET 6. It grows the backing array to at least n if it isn't already that big. Crucially, this is a single allocation, not the series of doublings you'd get from many Add calls. If you know up front you're going 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."
Cost: Always call EnsureCapacity (or pass capacity to the constructor) when you know the final size in advance and it's large. Doubling is fine for small lists, but for hundreds of thousands of items, eliminating the intermediate resizes is a real win.
Modifying a list while iterating it with foreach throws InvalidOperationException with the message "Collection was modified; enumeration operation may not execute." This is the single most common runtime error new C# developers hit 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 notices a mismatch, it bails.
The fix depends on what you're trying to do. If you want to remove all elements matching a condition, use RemoveAll:
If the logic is more complex, iterate backward by index so removals don't disturb the indices of elements you haven't visited yet:
Iterating backward works because RemoveAt(i) only shifts elements with indices greater than i. Since you've already visited those (you're moving from the end toward the start), the shift doesn't affect anything you still plan to see.
A third option, useful when you're 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 cleanest 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.
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 bites people who pass a list into a method expecting "they can read it but they can't modify it." Unless you pass a ReadOnlyCollection<T> or IReadOnlyList<T>, the receiver can mutate the same list you're holding.
If you need 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. To get a deep copy you'd need to clone each element as you build the new list. That's not something List<T> does for you; 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.
Cost: 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.
Here's the quick reference for List<T> operation costs. Pick a data structure by what's common in your access pattern.
| Operation | Cost | Notes |
|---|---|---|
list[i] read or write | O(1) | Direct array indexing. |
Add(item) | Amortized O(1) | Doubling on resize keeps the average constant. |
AddRange(seq) | O(k) where k = seq length | Bulk 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/set | O(1) get; O(n) set if it changes | Setting reallocates the backing array. |
When most of your operations are at the end and you need fast indexed access, List<T> is the right call. When you need fast insert and remove at both ends, look at Deque-style structures or LinkedList<T> (the _LinkedList<T>_ lesson). When you need fast "is this in there," look at HashSet<T> (the _HashSet<T>_ lesson). When you need ordered iteration with fast lookup, SortedSet<T> or SortedDictionary<TKey, TValue> (the _SortedDictionary & SortedList_ and _SortedSet<T>_ lessons) are the fit.
List<T> is a generic, resizable, indexed collection backed by an internal array. Indexed access is O(1), and Add at the end is amortized O(1) thanks to a doubling growth strategy.Count is the number of elements in the list. Capacity is the length of the backing array. They aren't the same, and you always iterate by Count.new List<T>(capacity: n)), from a sequence, and via a collection initializer. C# 12 adds a fifth form, collection expressions ([1, 2, 3]).LinkedList<T> or queue-shaped structures instead.Find, FindAll, FindIndex, RemoveAll, and ForEach take delegates and let you express bulk operations cleanly. They're all O(n) but in a single pass.Sort uses introsort (O(n log n), not stable, in place). Pass a Comparison<T> or an IComparer<T> to customize ordering.foreach throws InvalidOperationException. Use RemoveAll, iterate backward with for, or rebuild with LINQ instead.List<T> is a reference type. Assigning a list variable copies the reference, not the elements. Construct a new list with new List<T>(source) for a shallow copy, and remember that reference-type elements are still shared.EnsureCapacity and the capacity constructor parameter eliminate resize overhead when the final size is known. TrimExcess releases unused slack after a list is finished growing.The _LinkedList<T>_ lesson looks at a different collection shape: doubly-linked nodes with O(1) insert and remove at any node you already have a reference to, in exchange for O(n) indexed access and worse cache behavior. The trade-offs against List<T> come up often in interviews, and the lesson covers both the mechanics and when to pick one over the other.