How arrays really work
in C# and .NET 10
From new int[5] all the way down to cache lines and CPU registers. Play with simulators, see the memory, and learn exactly when an array is the right tool, and when it isn't.
Why arrays matter
An array is a fixed number of same-typed values stored side by side in one block of memory. That single idea makes it the fastest general-purpose container in .NET and the building block under List<T>, string, Dictionary, Span<T> and almost everything else.
🏨 The hotel-corridor analogy
Press play to walk through it.
⚡ Strengths
Instant index access, tiny overhead (24 bytes per array on 64-bit), best possible cache use, SIMD-friendly, zero-copy views with Span<T>.
⚠️ Weaknesses
Size is fixed at creation. Inserting or removing in the middle means shifting elements. Searching unsorted data is O(n). Arrays are always mutable.
🌍 Everywhere
Image pixels, audio samples, network buffers, game entities, matrices, lookup tables, ML embeddings, and the storage behind List<T>.
From your code to the silicon
When you write arr[i] it passes through seven layers before a value reaches the CPU. Tap a layer to see what it does, how big it is, who owns it and how much you control.
What an array looks like in memory
Every .NET array is an object on the managed heap. On 64-bit it starts with an 8-byte object header, an 8-byte MethodTable pointer (its type), a 4-byte Length plus 4 bytes of padding, then the elements packed back to back. Each row below is one 64-byte cache line.
Tap any coloured byte to see its address formula.
Assumes the object starts on a cache-line boundary, which .NET does not guarantee (objects are 8-byte aligned). Layout shown is CoreCLR x64/Arm64; it's an implementation detail and can change.
🧮 Size calculator
How long things take
Arrays are fast because they cooperate with the hardware. These are typical, rounded figures for a modern desktop/server CPU (they vary a lot by hardware, clock speed and load). The bar length uses a log scale.
The lesson: one trip to RAM costs as much as ~100 cache hits. Arrays win because a single trip brings in 64 bytes of neighbours, and the hardware prefetcher fetches the next lines before you ask.
Core concepts, one at a time
Each tab is one idea with a tiny hands-on demo. Start on the left and work right.
Creating arrays
Every new array is zero-initialised: numbers become 0, bool becomes false, references become null. The length is fixed for its whole life.
Pick a line of code above.
Indexing is just arithmetic
To find arr[i] the CPU computes address = arrayRef + 16 + i × sizeof(T) in a single instruction. That's why access time doesn't depend on the array's size. C# 8 added from-end indices: arr[^1] means arr[arr.Length - 1].
Bounds checks keep you safe
Unlike C, .NET checks every index. Reading arr[8] on an 8-element array throws IndexOutOfRangeException instead of reading random memory. The check is one compare-and-branch, and the JIT removes it when it can prove the index is valid (see Chapter 8).
Value types are stored inline, reference types are stored as pointers
An int[] or Point[] (struct) holds the actual values in the block. A string[] or Customer[] (class) holds 8-byte references; the objects live elsewhere on the heap. That extra hop costs a possible cache miss per element.
Assigning an array copies the reference, not the data
Arrays are reference types. b = a makes two names for one block. To get independent data you must copy.
Choose how b is created, then mutate a.
Rectangular int[,] vs jagged int[][]
A multi-dimensional array is one block with extra per-dimension lengths in its header. A jagged array is an array of references to separate row arrays, which may have different lengths. In .NET the jagged form is usually faster (the JIT optimises 1-D access better) and analyzer rule CA1814 suggests it when rows differ in size; the rectangular form is one allocation and simpler for true grids.
Ranges: copy vs view
arr[start..end] on an array allocates a new array and copies. arr.AsSpan(start..end) creates a Span view over the same memory: no copy, no allocation.
Array covariance: an old trap
C# lets a string[] be used where an object[] is expected. To stay type-safe the runtime must check every store into a reference-type array, and a wrong store throws ArrayTypeMismatchException. This check is also a small hidden cost; sealed element types and value types avoid most of it.
Arrays can't grow: "resizing" means copying
Array.Resize(ref arr, n) allocates a new array, copies the old elements and points your variable at it. The old array becomes garbage. Doing this once per added item is O(n²); List<T> doubles capacity instead (Chapter 9).
The state of the art (October 2026)
.NET 10 (November 2025, Long-Term Support) with C# 14 is the current LTS. .NET 11 with C# 15 reached Release Candidate 1 with a go-live licence in September 2026; general availability is planned at .NET Conf, 10–12 November 2026. Here's how array-related features arrived.
| Feature | Since | What it does for arrays |
|---|---|---|
ArrayPool<T> | .NET Core 1.0 (System.Buffers) | Rent and return reusable arrays instead of allocating. |
Span<T>, Memory<T> | C# 7.2 · .NET Core 2.1 | Safe, zero-copy views over arrays, stack memory or native memory. |
Indices ^1 and ranges a..b | C# 8 · .NET Core 3.0 | From-end access and slicing syntax. |
Pinned Object Heap, GC.AllocateUninitializedArray | .NET 5 | Allocate pinned buffers for interop; skip zeroing when you overwrite everything. |
Array.MaxLength | .NET 6 | The real upper bound on element count (0x7FFFFFC7). |
List patterns, u8 literals | C# 11 · .NET 7 | arr is [1, .., var last]; "GET"u8 is a ReadOnlySpan<byte> with no allocation. |
Vector512<T> / AVX-512, TensorPrimitives, Frozen collections | .NET 8 | Process 16 floats per instruction; ready-made SIMD maths over spans; fast read-only lookups. |
Collection expressions [1, 2, ..rest], [InlineArray] | C# 12 · .NET 8 | One syntax for arrays, lists and spans; fixed-size arrays embedded inside structs. |
params ReadOnlySpan<T> (params collections) | C# 13 · .NET 9 | Variadic methods without allocating a hidden array. |
| Loop optimisations (induction-variable widening, strength reduction) | .NET 9 JIT | Tighter array loops. |
| First-class span conversions | C# 14 · .NET 10 | Arrays convert implicitly to Span<T>/ReadOnlySpan<T>, even for extension methods and generic inference. |
| Stack-allocated small arrays, array-interface devirtualisation | .NET 10 JIT | Small fixed-size arrays that never escape a method can live on the stack; foreach over an array seen as IEnumerable<T> gets much cheaper. |
Collection expression arguments [with(capacity: 100), ..] | C# 15 · .NET 11 (RC) | Pass constructor args (capacity, comparer). Not supported for arrays or spans. |
| Higher CPU baseline (x86-64-v2) | .NET 11 (RC) | SSE4.2/POPCNT guaranteed; ReadyToRun code targets x86-64-v3 (AVX2) on Windows and Linux. |
| Operation on T[] | Cost | Why |
|---|---|---|
Read / write arr[i] | O(1) | One address calculation and one load/store. |
arr.Length | O(1) | Stored in the object at offset +8. |
Linear search (IndexOf, Contains) | O(n) | Checks each element, but vectorised for primitives. |
Array.BinarySearch (sorted) | O(log n) | Halves the range each step. |
Array.Sort | O(n log n) | Introspective sort, in place, not stable. |
| Insert / remove in the middle | O(n) | Must shift later elements, or copy into a new array. |
| "Append" (Array.Resize) | O(n) | New array + full copy every time. |
new T[n] | O(n) | The memory must be zeroed. |
Why access order matters: the cache-line simulator
Memory below is a long[128] drawn as 16 cache lines of 8 elements (64 bytes each). The tiny model "L1 cache" holds 4 lines and evicts the least-recently-used. A hit costs ~1 ns, a miss ~80 ns. Same data, same work, very different speed.
L1 cache (4 lines, LRU)
Real-world rule: walk arrays front to back, walk 2-D data in the order it is stored (row by row in C#), keep hot data in contiguous arrays of structs or separate arrays, and avoid chasing pointers when performance matters.
The JIT, bounds checks and how to get them removed
The JIT (just-in-time compiler, RyuJIT) turns your IL into machine code at run time. With tiered compilation and Dynamic PGO (profile-guided optimisation, on by default since .NET 8) hot loops are recompiled with full optimisation. One optimisation that matters for arrays is bounds-check elimination: if the JIT can prove 0 ≤ i < Length, the check disappears.
Simplified x64 loop body
Assembly is simplified for teaching. Check the real output with DOTNET_JitDisasm="Sum" (.NET 7+) or BenchmarkDotNet's [DisassemblyDiagnoser]. Exact results depend on .NET version, tier and surrounding code.
🧠 Why a check is cheap
It's one cmp + one branch the CPU predicts correctly almost every time. Note the unsigned compare (jae): it rejects negative indices in the same instruction.
🐢 Why it still matters
In tight loops a remaining check can block vectorisation and loop unrolling, so the real loss can be 2× or more, not just one instruction.
🧰 Tricks
Loop to arr.Length or span.Length; copy fields to locals; prefer foreach/Span; as a last resort use MemoryMarshal.GetArrayDataReference + Unsafe.Add (no checks, you own the risk).
How List<T> fakes a growable array
List<T> wraps a private T[]. When it's full, it allocates an array twice as big (starting at 4), copies everything over and drops the old one. That makes Add amortised O(1), but each growth is a full copy and creates garbage.
Takeaway: if you know (or can estimate) the final count, pass it as the capacity, or build a T[] directly. Use CollectionsMarshal.AsSpan(list) to loop over a list's backing array without the enumerator (don't add/remove while holding that span).
What you control vs what's decided for you
You can't pick a memory address or tell the CPU what to cache, but your choices decide almost everything that matters for performance.
✅ You decide
- Element type: struct (inline) vs class (pointer per element)
- Size, and whether it's known up front
- Data shape: array of structs vs struct of arrays
- Where it lives: heap, pool,
stackalloc,[InlineArray], native memory - Access order and loop shape (affects bounds checks and SIMD)
- Whether to copy (
ToArray, ranges) or view (Span) - Pinning for interop:
fixedorGC.AllocateArray(pinned: true) - Explicit SIMD:
Vector<T>,Vector128/256/512,TensorPrimitives
⚙️ Decided for you
- Exact address and alignment (8-byte aligned objects)
- Zeroing of new memory (unless you ask for uninitialized)
- SOH vs LOH placement (≥ 85,000 bytes ⇒ LOH)
- When the GC runs, and whether it moves (compacts) your array
- Which bounds checks the JIT can remove; tiering and PGO
- Whether a small local array gets stack-allocated (.NET 10 escape analysis)
- Cache sizes, cache-line size, prefetching, branch prediction
- Which SIMD width is hardware-accelerated on the machine
How the pieces link: C# on the left, hardware on the right
Tap any row to highlight the pair. Every C# array feature maps onto a concrete machine-level action.
When to use an array, and when not to
Arrays are the default for fixed-size, index-based, hot-path data. For everything else .NET has a better-shaped container. Answer the questions to get a recommendation, then check the comparison table.
🧭 Decision helper
| Type | Size | Index O(1) | Lookup by key | Insert middle | Heap alloc | Across await / in fields | Best for |
|---|---|---|---|---|---|---|---|
T[] | Fixed | ✅ | ❌ O(n) | ❌ O(n) | Yes (maybe stack in .NET 10) | ✅ | Known-size data, hot loops, buffers |
List<T> | Growable | ✅ | ❌ O(n) | ⚠️ O(n) | Yes (+ regrowth) | ✅ | Unknown count, general purpose |
Span<T> / ReadOnlySpan<T> | View | ✅ | ❌ | ❌ | Never (it's a view) | ❌ stack only | Slicing, parsing, sync hot paths |
Memory<T> | View | via .Span | ❌ | ❌ | No | ✅ | Async I/O buffers |
ArrayPool<T> rent | ≥ requested | ✅ | ❌ | ❌ | Reused | ✅ (return it!) | Temporary large buffers |
stackalloc | Fixed, small | ✅ | ❌ | ❌ | No (stack) | ❌ | Tiny scratch buffers (< ~1 KB) |
[InlineArray(N)] struct | Fixed, compile-time | ✅ | ❌ | ❌ | Inside its container | ✅ | Fixed buffers inside structs, interop |
ImmutableArray<T> | Fixed | ✅ | ❌ | New copy | Yes | ✅ | Shared read-only data, thread-safe |
Dictionary<K,V> / HashSet<T> | Growable | ❌ | ✅ O(1) avg | n/a | Yes | ✅ | Lookups by key, uniqueness |
FrozenDictionary / FrozenSet | Fixed | ❌ | ✅ fastest reads | ❌ | Yes (built once) | ✅ | Read-mostly lookup tables |
LinkedList<T> | Growable | ❌ O(n) | ❌ | ✅ O(1) at a node | Per node | ✅ | Rare: frequent splice with node handles |
Queue<T> / Stack<T> | Growable | ❌ | ❌ | Ends only | Yes (array-backed) | ✅ | FIFO / LIFO work lists |
Modern .NET APIs for arrays
These tools let you keep array-level speed while avoiding allocations and copies. All examples target .NET 10 / C# 14; notes mark where something needs a newer or older version.
One syntax for arrays, lists and spans. The compiler picks the cheapest construction for the target type: constant ReadOnlySpan<byte> data is baked into the assembly, and a Span<T> target may use the stack.
A Span<T> is just a reference + a length (two registers). Slicing it is free. It's a ref struct: it can't be boxed, stored in a class field, or used across await; use Memory<T> there.
Rent may return a larger array than you asked for, and its contents are not cleared. Always track your own length and return in finally.
stackalloc carves memory out of the current thread's stack (often 1 MB by default on Windows, more on Linux). Keep it small and never inside an unbounded loop.
[InlineArray(N)] (C# 12) makes a struct whose single field is repeated N times, giving a real fixed-size array inside another object or on the stack with indexing, foreach and span conversion.
SIMD (single instruction, multiple data) processes several elements per instruction: 8 ints with AVX2 (256-bit), 16 with AVX-512. Contiguous arrays are what make this possible. Prefer TensorPrimitives or built-in vectorised methods before writing your own.
The Array class and MemoryExtensions already contain vectorised, well-tested helpers. Reach for them before writing loops.
For the rare hot loop where a profiler proves bounds checks matter. ❌ Wrong indices here corrupt memory silently, so wrap it in a well-tested method.
Proven array patterns
Five shapes you'll see in fast .NET code. Watch the dot travel through each flow.
Measure, don't guess: BenchmarkDotNet
BenchmarkDotNet (0.15.x is current in 2026; recent versions add Roslyn analyzers that catch mistakes) handles warm-up, JIT tiering, statistics and allocation counting for you. Never time array code with a single Stopwatch run in Debug mode.
📊 How to read the results
Illustrative shape of results for summing 10,000 ints on a modern x64 machine. Your numbers will differ; the ratios are what to look at.
- Mean
- Average time per call
- Ratio
- Relative to the [Benchmark(Baseline = true)] method
- Allocated
- Managed bytes allocated per call (from [MemoryDiagnoser])
- Gen0
- Gen 0 collections per 1,000 calls
✅ Do
- Run
dotnet run -c Release - Add
[MemoryDiagnoser] - Use
[Params]for several sizes (fits-in-cache vs not) - Return results so work isn't optimised away
❌ Don't
- Benchmark with a debugger attached
- Allocate test data inside the benchmark method
- Trust one run or one machine
- Compare across .NET versions without saying so
🔎 Other tools
dotnet-countersfor live GC/LOH statsdotnet-trace+ PerfView / VS profiler for allocations[DisassemblyDiagnoser]to see bounds checks- Object layout via the SOS
!dumpobjcommand
Common problems and their fixes
The mistakes that show up again and again in code reviews and production incidents.
Fix the slow upload endpoint
An ASP.NET Core endpoint receives ~1 MB CSV uploads, parses them, computes a grid of totals and a checksum. It's slow and has GC spikes. Turn on fixes and run 1,000 requests to see the effect. Numbers come from a simplified teaching model, not a real benchmark.
🛠 Fixes
📈 Results
Real-world scenarios
Situations you'll meet in real projects and what helps. Filter by area.
Cheat sheet
Everything important on one page.
Glossary
Every term used in this guide, searchable.