C# List vs LinkedList: When the Difference Matters
Compare C# List<T> and LinkedList<T> on storage, indexing, insertion cost, and memory behavior to choose the right collection for your workload.
The common question is whether switching from List<T> to LinkedList<T> will make code faster. The answer is rarely a simple yes. The two collections solve different access patterns, and choosing the wrong one can make code slower and harder to maintain.
How Each Collection Stores Its Elements
List<T> is backed by a contiguous array. When the array fills, the list allocates a larger array and copies the existing elements into it. Every element sits at a fixed offset from the start of the buffer, which is why indexed access is O(1).
LinkedList<T> is a doubly-linked list. Each element is wrapped in a LinkedListNode<T> object that holds the value plus references to the previous and next nodes. There is no array and no index. To reach the nth element, the runtime must walk the chain from either end.
This structural difference drives every other behavior, so it is worth keeping in mind when comparing the two.
Indexed Access and Iteration
List<T> exposes an indexer, so list[5] is a direct array read. It also implements IList<T>, which means it can be passed to APIs that expect random access.
LinkedList<T> does not implement IList<T> and has no indexer. Calling LINQ's ElementAt(n) on a LinkedList<T> walks the nodes one by one, making it O(n) per call. If code frequently accesses elements by position, LinkedList<T> is the wrong choice.
Iteration is also different. A List<T> iterator reads sequentially from contiguous memory, which the CPU prefetcher handles well. A LinkedList<T> iterator follows object references scattered across the heap, so each step can be a cache miss. For large collections, iterating a LinkedList<T> is generally slower even though both have O(n) iteration complexity.
Insertion and Removal Costs
The common claim is that LinkedList<T> has O(1) insertion and removal while List<T> is O(n). That claim is only true under specific conditions.
List<T> insertion at the end is amortized O(1) because of capacity doubling. Insertion in the middle requires shifting every subsequent element, which is O(n). Removal in the middle has the same shifting cost.
LinkedList<T> insertion at either end is O(1), and removing from either end is O(1). Inserting before or after a known node is also O(1) when you already hold the relevant LinkedListNode<T> reference. For example:
var linked = new LinkedList<string>(); var first = linked.AddFirst("first"); var second = linked.AddAfter(first, "second"); linked.AddBefore(second, "between");
Each operation here uses a node reference directly, so no traversal is needed. Removing a known node, or removing from either end, is also O(1):
linked.Remove(second); // O(1) because second is a known node
If you instead pass a value to Remove(value), the list must scan to find the matching node first, which is O(n).
The Practical Trap: Searching Before Inserting
Most real-world middle-insertion scenarios do not start with a node reference. They start with a value or a position. Consider inserting an element after a specific value:
var linked = new LinkedList<int>(); // ... populate the list ... var node = linked.Find(42); // O(n) scan if (node != null) { linked.AddAfter(node, 43); }
The Find call is O(n), so the overall operation is O(n) even though the insertion itself is O(1). The same is true for List<T>: finding the position is O(n), and the shift is O(n). In this scenario, LinkedList<T> does not win.
The clearest advantage for LinkedList<T> comes when the application keeps node references alive across operations. A classic example is an LRU cache, where you store the node alongside the cached value and move it to the front on every hit:
var node = cacheNodes[key]; linked.Remove(node); linked.AddFirst(node);
Here the node reference is already available, so both operations are O(1). Rebuilding the same behavior with List<T> would require an O(n) removal followed by an O(n) insertion.
Memory Footprint and Cache Behavior
List<T> allocates a single array. The memory overhead is the unused capacity at the end of the buffer. If the list is nearly full, that overhead is small.
LinkedList<T> allocates a separate node object for every element. Each node carries the value, two references, and object header overhead. For value types, the value is stored inside the node. The total per-element cost is several times larger than a single array slot. In addition, nodes are allocated individually, so they are scattered across the heap and the garbage collector must track many small objects.
For large collections, the combination of higher memory usage and poor cache locality usually makes LinkedList<T> slower than List<T> for iteration and for most access patterns, even when the asymptotic complexity looks better on paper.
API and Compatibility Differences
LinkedList<T> does not implement IList<T>, so it cannot be used where random access is expected. It also lacks BinarySearch, Sort, and the indexer. If a method signature accepts IList<T>, a LinkedList<T> cannot be passed in.
List<T> supports collection initializers and works naturally with APIs that expect a list interface. LinkedList<T> works with LINQ through IEnumerable<T>, but operations like ElementAt and Contains are O(n) because there is no index to use for lookup. Count remains O(1) through ICollection<T>.Count.
Modifying either collection while enumerating it throws an InvalidOperationException. Neither collection is safe for concurrent writes without external synchronization.
When to Use LinkedList<T> in Real Code
LinkedList<T> is a reasonable choice when the collection is modified frequently at the ends or at known node positions, node references are held by the application and reused across operations, random access by index is never needed, and the collection size is large enough that per-node overhead is acceptable.
The LRU cache pattern above is the most common legitimate use. Another is a playlist or undo history where you navigate between items using node references and insert or remove entries at the current position.
For almost everything else, List<T> is the better default. It uses less memory, iterates faster, supports indexed access, and integrates with the broader .NET collection APIs. If profiling shows that middle insertion is a real bottleneck, the fix is usually a different data structure entirely, such as a Dictionary<TKey, TValue> for lookups or a custom indexed structure, rather than a linked list.
| Criterion | List<T> | LinkedList<T> |
|---|---|---|
| Indexed access | O(1) | Not supported |
| Insert at end | Amortized O(1) | O(1) |
| Insert in middle | O(n) shift | O(1) with node reference |
| Memory per element | Array slot | Node object + two references |
| Cache locality | Good | Poor |