Single-dimensional arrays in C# 14: declaration and collection expressions, fixed length and zero-initialisation, the Array helper methods, copying versus slicing, ranges that copy, and choosing between arrays and List<T>.
An array is a fixed-length, zero-based, contiguous block of elements of one type. T[] is a reference type, and new int[5] gives five zeros. I create them with new T[n], initialisers or collection expressions like int[] ids = [1, 2, 3]. The length never changes: Array.Resize actually allocates a new array and copies. Clone is a shallow copy, arr[1..3] copies a range while arr.AsSpan(1..3) is a free view, and Array.Sort/BinarySearch are the built-in algorithms. I use arrays for fixed-size data, buffers and hot loops, and List<T> when the number of items changes.
A single-dimensional array (T[]) is a fixed-length, contiguous block of elements in one heap object, indexed from zero, with bounds checking on every access. It is the most fundamental collection in .NET — List<T>, Dictionary, string and Span<T> are all built on arrays. I cover the everyday operations and then when an array is a better choice than List<T>.
int[] a = new int[5]; // five zeros (default values)
int[] b = new int[] { 1, 2, 3 };
int[] c = { 1, 2, 3 }; // array initialiser
int[] d = [1, 2, 3]; // C# 12 collection expression
var e = new[] { 1.5, 2.5 }; // inferred double[]
string[] names = new string[3]; // three nulls
int[] empty = []; // uses a cached empty array
a.Length never changes.default(T).Array.Empty<T>() (or []) returns a cached zero-length array, so returning empty arrays allocates nothing.new T[n] with a huge n allocates on the Large Object Heap once it exceeds ~85,000 bytes.int first = d[0];
int last = d[^1]; // index from end (C# 8)
int[] mid = d[1..]; // RANGE ON AN ARRAY ALLOCATES A COPY
Span<int> view = d.AsSpan(1); // a view - no copy
Ranges on arrays call RuntimeHelpers.GetSubArray, which copies. Ranges on spans are free views. This distinction matters in hot paths.
| Operation | Behaviour |
|---|---|
int[] copy = a; |
Copies the reference — same array |
a.Clone() |
Shallow copy, returns object (needs a cast) |
Array.Copy(src, srcIndex, dest, destIndex, length) |
Copies a range; handles overlapping ranges correctly |
a.CopyTo(dest, index) |
Copies all elements |
a.AsSpan().CopyTo(dest) |
Fastest, vectorised (memmove) |
a.ToArray() (LINQ) / [.. a] |
New array |
All copies are shallow: for reference-type elements, both arrays point to the same objects.
Arrays can't grow. Array.Resize(ref a, newSize) allocates a new array, copies the elements and reassigns your variable — other references still point to the old array. If you're resizing repeatedly, you want a List<T> (which does exactly this with geometric growth).
| Need | API | Cost |
|---|---|---|
| Linear search | Array.IndexOf(a, x), a.AsSpan().IndexOf(x), Array.Exists, Array.Find |
O(n), vectorised for primitives |
| Sort | Array.Sort(a), Array.Sort(a, comparer), a.AsSpan().Sort() |
O(n log n) introsort, unstable |
| Binary search | Array.BinarySearch(a, x) |
O(log n), array must be sorted with the same comparer |
| Reverse | Array.Reverse(a) / span.Reverse() |
O(n) |
| Fill | Array.Fill(a, value) / span.Fill(value) |
O(n), vectorised |
| Clear | Array.Clear(a) / span.Clear() |
O(n) |
Array.BinarySearch returns the bitwise complement of the insertion point when not found (~index), which is useful for maintaining a sorted array.
List<T>new Result[items.Count] filled by index).Span<T>.byte[] buffers for streams, sockets, cryptography, ArrayPool<T> rentals.ImmutableArray<T>, which wraps one) when callers should not change your state.I choose List<T> when items are added or removed, or the count is unknown until the end. For public APIs I usually return IReadOnlyList<T> or ImmutableArray<T> and accept IEnumerable<T>/IReadOnlyList<T>/ReadOnlySpan<T>, rather than exposing mutable arrays.
A pricing engine looked up tax rates and discounts per product category using a Dictionary<int, decimal> built per request. The categories were a dense range 0..255. Replacing it with a static readonly decimal[] RatesByCategory indexed by category ID (loaded once at start-up, exposed as ReadOnlySpan<decimal>) made the lookup a single bounds-checked array access, removed per-request dictionary allocation, and was easy to reason about. The trade-off — a fixed, dense key range — was acceptable because categories were controlled internally.
A fundamentals check that also tests awareness of copying, mutability and API choice.
Green flags
Resize reallocates and Clone is shallow.List<T> guidance and avoids exposing mutable arrays.Red flags
null instead of empty arrays.public static int[] lookup tables.## 1. Array object layout
```text
[ header 8B ][ MethodTable* 8B ][ Length 8B (4B length + padding) ][ element 0 ][ element 1 ] ...
```
- An `int[1000]` is one object of ~4,024 bytes: elements are stored **inline and contiguously**.
- A `string[1000]` stores 1,000 **references** (8 bytes each); the strings themselves are separate objects.
- The `MethodTable` describes the element type, which the runtime uses for type checks (array covariance) and the GC uses to know whether elements contain references.
- Arrays of value types without references (`int[]`, `double[]`) are not scanned by the GC for pointers — cheaper to collect.
## 2. Indexing and bounds checks
`a[i]` compiles to the IL `ldelem` / `stelem`, which the JIT turns into:
```text
cmp i, [array + 8] ; compare with Length
jae ThrowIndexOutOfRange
mov eax, [array + 16 + i*4]
```
The unsigned comparison (`jae`) catches both negative and too-large indexes in one test. The JIT **removes** this check when it can prove `i` is in range — most notably in `for (int i = 0; i < a.Length; i++)` loops, and in `foreach` over arrays.
## 3. Zero-length arrays and `Array.Empty
using System.Collections.Immutable;
namespace Arrays;
public sealed record Person(string Name, int Age);
public static class SingleDimensional
{
// Do not expose mutable arrays publicly
public static ImmutableArray<decimal> GstBrackets { get; } = [0m, 0.10m];
public static void Main()
{
int[] scores = [72, 95, 58, 88];
Console.WriteLine($"Length {scores.Length}, last {scores[^1]}");
// Sort and search
Array.Sort(scores); // [58, 72, 88, 95]
int found = Array.BinarySearch(scores, 88); // 2
int missing = Array.BinarySearch(scores, 80); // negative: ~insertion point
Console.WriteLine($"{found} {~missing}"); // 2 2
// Copy vs view
int[] copy = scores[1..3]; // new array [72, 88]
ReadOnlySpan<int> view = scores.AsSpan(1..3); // no allocation
Console.WriteLine($"{copy.Length} {view[0]}");
// Resize allocates a new array
Array.Resize(ref scores, 6); // [58, 72, 88, 95, 0, 0]
// Shallow copy shares reference-type elements
Person[] people = [new("Ada", 36)];
var clone = (Person[])people.Clone();
Console.WriteLine(ReferenceEquals(people[0], clone[0])); // True
// Stable sort when order of equal keys matters
Person[] staff = [new("Bo", 30), new("Al", 30), new("Cy", 25)];
var byAge = staff.OrderBy(p => p.Age).ToArray(); // Cy, Bo, Al (stable)
Console.WriteLine(string.Join(",", byAge.Select(p => p.Name)));
Console.WriteLine(Find([], 5).Length); // 0: return empty, not null
}
private static int[] Find(int[] source, int min) =>
source.Length == 0 ? [] : Array.FindAll(source, x => x >= min);
}
What happens: int[] backup = data; then modifying data.
Why it is wrong: both variables reference the same array.
Do instead: copy explicitly (data.ToArray(), [.. data], Array.Copy).
What happens: cloning an array of objects and modifying an element's properties in the copy. Why it is wrong: both arrays share the same element objects. Do instead: deep-copy elements explicitly, or use immutable element types (records).
Array.Resize in loopsWhat happens: appending items by resizing by one each time.
Why it is wrong: O(n²) copying and many garbage arrays.
Do instead: use List<T> (geometric growth) or pre-size when the count is known.
What happens: var tail = buffer[1..]; inside loops.
Why it is wrong: array ranges allocate a copy each time.
Do instead: use buffer.AsSpan(1..) for views.
What happens: Array.BinarySearch on data sorted with a different comparer or not sorted at all.
Why it is wrong: results are undefined — often wrong without any exception.
Do instead: sort with the same comparer used for searching, or use HashSet<T> for membership.
What happens: public string[] AllowedRoles => _roles;.
Why it is wrong: callers can modify elements and change your internal state (even with readonly on the field).
Do instead: return IReadOnlyList<T>, ImmutableArray<T>, ReadOnlySpan<T>, or a copy.
new T[0]What happens: empty results are returned as new Order[0].
Why it is wrong: allocates a new object every time.
Do instead: return Array.Empty<T>() or [].
^0What happens: a[a.Length] or a[^0] to get the last element.
Why it is wrong: both are out of range; the last element is a[^1].
Do instead: use a[^1] and loop with < Length.
Array.Sort to be stableWhat happens: sorting already-ordered data by a secondary key and expecting the previous order to be preserved for ties.
Why it is wrong: introsort is unstable.
Do instead: use LINQ OrderBy(...).ThenBy(...) or sort by a composite key.
What happens: fixed arrays with a separate count variable managed by hand.
Why it is wrong: reinvents List<T> with more bugs.
Do instead: use List<T>, or ArrayPool<T> with clear ownership when you truly need manual buffers.
Arrays are the building block under most collections; seniors use them deliberately and keep them encapsulated.
| Use arrays when | Prefer something else when |
|---|---|
| size is known and fixed | items are added and removed (List<T>) |
| you need the fastest indexing and iteration | you need set or key lookups (HashSet, Dictionary) |
interop and buffers (byte[], char[]) |
data is shared and must not change (ImmutableArray<T>, FrozenSet<T>) |
| sorting large data in place | you need stable ordering (OrderBy) |
ImmutableArray<T> is a thin wrapper over an array with no extra indirection, ideal for exposing fixed data safely.double[10_625]) landing on the LOH in frequently allocated code; pool or reuse them.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.
A rectangular array int[,] is one object with every element stored contiguously in row-major order, and every row is the same length. A jagged array int[][] is an array of separate row arrays, so rows can have different lengths but each access goes through an extra reference. Rectangular arrays use helper-style access in IL and were historically slower, while each jagged row is a fast single-dimensional array that works with spans. The modern JIT has narrowed the gap, so I measure. For performance-critical grids I often flatten into one T[] with index = row * width + col, which gives contiguous memory, span and SIMD support. I always iterate row by row for cache locality.
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.
Last updated 30 Oct 2026.