Back to Blog
C#

C# Array vs List: Choosing the Right Collection Type

c# array vs list: Compare C# arrays and List<T> for fixed-size and dynamic collections, covering memory, performance, API differences, and when each type fits best.

arraysList<T>collectionsperformancememoryLINQ
Side-by-side comparison of a fixed-size C# array and a dynamic List<T> collection

When you need to store a sequence of values in C#, the two most common choices are a fixed-size array and a List<T>. The decision between c# array vs list comes down to whether you know the element count at compile time, how frequently the collection changes size, and what operations you need to perform on it. Both types store elements in contiguous memory, but their runtime behavior differs in ways that matter for performance and maintainability.

The Core Difference: Fixed Capacity vs Dynamic Growth

An array declares its length once, and that length cannot change for the lifetime of the instance. A List<T> starts with an internal array buffer and replaces it with a larger buffer when the current capacity is exhausted. That single difference drives most of the tradeoffs between the two types.

int[] numbers = new int[5]; // exactly 5 elements, forever List<int> dynamicNumbers = new(); // grows as you add elements

When you call Add on a List<T> and the internal buffer is full, the list allocates a new array, typically doubling the capacity, and copies every existing element into it. This is amortized O(1) per add operation, but individual adds can be expensive when a resize occurs. An array never resizes, so indexed access is always a direct memory lookup with no capacity checks beyond bounds validation.

Declaration and Initialization Syntax

Arrays and lists have similar but not identical initialization syntax. Arrays support collection expressions, multidimensional forms, and implicit typing in ways that lists do not always mirror.

int[] array1 = { 1, 2, 3 }; int[] array2 = new int[] { 1, 2, 3 }; int[,] matrix = new int[2, 3]; List<int> list1 = new() { 1, 2, 3 }; List<int> list2 = new List<int>(3); // initial capacity, not length

The List<T> constructor that accepts an integer sets the initial capacity, not the element count. This is a common source of confusion. If you know the final size is 1000 elements, passing 1000 to the constructor avoids repeated buffer resizes during a fill loop.

Arrays support multidimensional forms like int[,] and jagged arrays like int[][]. A List<List<int>> can approximate jagged arrays, but there is no direct multidimensional list type. If you need a grid or matrix structure, an array is usually the more natural representation.

Memory and Performance Behavior

Arrays are the most memory-efficient way to store a fixed number of elements. A List<T> carries additional state: the internal buffer reference, a count, and a version field used for enumeration change detection. The buffer itself is often larger than the element count, so a list can consume noticeably more memory than an array holding the same number of elements.

For indexed reads and writes, arrays and lists have nearly identical performance because both use direct offset-based access. The list adds a bounds check and a count field read, but the JIT compiler frequently optimizes these away in simple loops. The real cost difference appears in allocation and resizing.

int[] fixedBuffer = new int[100_000]; for (int i = 0; i < fixedBuffer.Length; i++) { fixedBuffer[i] = i; } List<int> growingBuffer = new(); for (int i = 0; i < 100_000; i++) { growingBuffer.Add(i); }

The array version allocates one block of 400,000 bytes for an int[100_000]. The list version starts with an empty internal buffer, allocates an initial capacity of 4 on the first add, then doubles the buffer each time it fills. For 100,000 elements, that means roughly 15 buffer replacements, with a total of about 131,000 element copies across all resizes. The array avoids all of that copying.

If you know the final size in advance, you can pass it to the List<T> constructor to eliminate most resizes:

List<int> preSized = new(100_000); for (int i = 0; i < 100_000; i++) { preSized.Add(i); }

This still allocates one buffer like the array, but the list object itself adds a small fixed overhead. The performance difference between a pre-sized list and an array is negligible for most workloads.

API and Method Differences

The API surface of the two types reflects their intended use. Arrays expose Length, lists expose Count. Both support foreach, LINQ extension methods, and indexed access, but the mutating operations exist only on the list.

OperationArrayList<T>
Fixed lengthYes, LengthNo, Count changes
Add elementNoAdd, AddRange
Insert at indexNoInsert
Remove elementNoRemove, RemoveAt
Sort in placeArray.SortSort method
Find elementArray.FindFind, IndexOf
ResizeArray.Resize (new array)Automatic

Array.Resize does not modify the original array. It creates a new array with the requested size, copies the elements, and returns the new reference. If you still hold the old reference, you are looking at the old data. This is a frequent source of bugs.

int[] data = { 1, 2, 3 }; Array.Resize(ref data, 5); // data now points to a new array

The ref keyword is required because the method replaces the reference. Lists handle this internally, so the equivalent operation is just list.Add or list.Capacity = 5.

Array Covariance and Type Safety

Arrays of reference types are covariant in C#. A string[] can be assigned to an object[] reference. This works at compile time but fails at runtime if you try to store a non-string object in the array.

string[] names = { "Alice", "Bob" }; object[] objects = names; // compiles objects[0] = 42; // throws ArrayTypeMismatchException

List<T> is invariant. A List<string> cannot be assigned to List<object>, which prevents this class of runtime error. If you need to pass a collection of strings to a method that accepts IEnumerable<object>, you can use LINQ's Cast<object>() or design the method to accept IEnumerable<T> with a generic type parameter.

This covariance difference matters when designing APIs. A method that accepts object[] can receive a string[], but the method must not write to the array unless it checks the runtime type. A method that accepts IEnumerable<object> is safer because it cannot mutate the underlying collection.

Choosing Between Array and List

The choice depends on the shape of the data and how it changes over time. Use an array when the element count is fixed and known at creation time, when you need multidimensional storage, or when you are building a high-performance hot path where avoiding allocation overhead matters. Use a List<T> when elements are added or removed at runtime, when the final count is unknown until data is processed, or when you need the collection to be passed around and mutated by different parts of the codebase.

A common practical rule: if you write new int[10] and never need to change the length, an array is correct. If you find yourself writing Array.Resize or tracking a separate count variable, a List<T> is the better tool because it encapsulates that logic.

For API design, prefer exposing IReadOnlyList<T> or IEnumerable<T> rather than a concrete array or list type. This lets the caller choose the implementation while keeping the contract clear. An array can be wrapped as IReadOnlyList<T> with no allocation, and a List<T> implements the interface directly.

Common Pitfalls and Edge Cases

Several subtle behaviors can trip up developers switching between arrays and lists. The Capacity property of a list is not the same as Count. Reading Capacity after adding elements shows the internal buffer size, which is often larger than the element count. Setting Capacity to a value smaller than Count throws an exception.

Empty arrays and empty lists behave differently in some APIs. Array.Empty<T>() returns a cached empty array that avoids allocation, while new List<T>() allocates a small object. In performance-sensitive code that creates many empty collections, Array.Empty<T>() is the cheaper choice.

int[] empty = Array.Empty<int>(); // cached, no allocation List<int> emptyList = new(); // allocates a List object

When passing a collection to a method that only reads it, an array is often the better parameter type because it cannot be accidentally resized by the caller. The caller cannot add or remove elements from an array, which makes the contract explicit. If the method needs to grow the collection, it should accept a List<T> or return a new collection rather than mutating the input.

The Span<T> type works directly with arrays through implicit conversion, but not with lists. If you need stack-allocated or slice-based processing, an array gives you Span<T> access without copying. Lists require CollectionsMarshal.AsSpan for that behavior, which is an advanced API with safety constraints.

LINQ Behavior Across Both Types

LINQ extension methods operate on both arrays and lists through IEnumerable<T>, but the performance characteristics differ. A foreach loop over an array compiles to a simple index-based loop that the JIT can optimize into efficient machine code. A foreach loop over a List<T> uses the list's enumerator struct, which avoids heap allocation but still involves method calls for MoveNext and Current.

For single-pass reads, the difference is small. For repeated enumeration, arrays have a slight edge because the JIT can vectorize index-based loops. If you are processing a large fixed dataset in a tight loop, an array will typically be faster than a list of the same size. The difference comes from the list's enumerator overhead and the capacity field checks, not from the underlying memory layout.

LINQ methods like Where, Select, and OrderBy allocate intermediate enumerable objects regardless of whether the source is an array or a list. The source type does not change the allocation behavior of the LINQ pipeline itself. If allocation pressure is a concern, a hand-written loop over an array avoids the LINQ overhead entirely.

c# array vs list: Practical Usage and Code Examples | RYUSLOG DEV