Array bounds checking in .NET 10: the check the JIT emits, why it gives memory safety, patterns that let the JIT remove it, patterns that keep it, how to inspect generated code, and the trade-offs of unsafe access.
Every array or span access is guarded by a bounds check that compares the index against Length and throws IndexOutOfRangeException. That's what makes .NET memory-safe: no buffer overruns. The JIT removes checks it can prove are redundant, for example for (int i = 0; i < arr.Length; i++) or foreach over arrays and spans. They stay when you loop to some other variable, index with computed values, or read the array from a field that might change. I write loops against the array's or span's own Length, copy fields to locals, slice to spans, and check the generated assembly with DOTNET_JitDisasm or BenchmarkDotNet. Unsafe/MemoryMarshal tricks are a last resort for proven hot paths.
Bounds checking means every array (and span, string, List<T>) access verifies the index is within 0..Length-1 and throws IndexOutOfRangeException otherwise. It is a core part of .NET's memory safety: without it, a bad index would read or overwrite arbitrary memory — the source of buffer overflow vulnerabilities in C and C++. The JIT removes the check whenever it can prove the index is safe, so the senior skill is writing loops the JIT recognises, measuring when it matters, and reaching for unsafe only rarely.
A bounds check is a compare and a conditional branch:
cmp edx, dword ptr [rcx+8] ; index vs Length
jae THROW ; unsigned compare also catches negative indexes
The branch is almost always predicted correctly, so the cost is typically ~1 cycle per access — but in tight numeric loops it adds instructions, can block vectorisation, and prevents some loop optimisations. In most business code it is irrelevant; in parsers, image processing, compression and maths kernels it can matter by 10–30%.
The JIT (RyuJIT) uses range analysis and pattern recognition. Common patterns that are check-free:
for (int i = 0; i < array.Length; i++) sum += array[i]; // canonical loop: no check
foreach (var x in array) sum += x; // compiled to the same loop
for (int i = 0; i < span.Length; i++) sum += span[i]; // spans too
if ((uint)i < (uint)array.Length) return array[i]; // explicit unsigned range check -> no second check
Techniques the JIT uses:
i starts at 0, increments by 1, and the loop condition is i < arr.Length on the same array.i < n), the JIT may create a fast clone guarded by n <= arr.Length and a slow clone with checks.if (i < arr.Length) or a previous access at a higher index, later accesses are proven safe. Accessing the highest index first (_ = arr[3]; then arr[0..3]) lets the JIT drop the remaining checks.stackalloc with constant size, u8 literals, static readonly arrays after tier-1).Things that defeat it: using a different array's length, caching length in a field, indexes computed with non-trivial maths, loops counting down from a non-length value, accessing through IList<T>, and reading Length from an array that might change (fields that could be reassigned).
for (i = 0; i < span.Length; i++) or foreach.var slice = data.AsSpan(start, count); for (int i = 0; i < slice.Length; i++) ... — one check at slicing, none in the loop.var arr = _array;) so the JIT knows the array won't change during the loop.span.IndexOf, SequenceEqual, Fill, CopyTo, TensorPrimitives.Sum, SearchValues.DOTNET_JitDisasm, SharpLab, BenchmarkDotNet's [DisassemblyDiagnoser]) rather than guessing.unsafe code is justifiedMemoryMarshal.GetReference + Unsafe.Add, or fixed pointers, remove checks entirely — and with them, all safety. I consider it only when:
Vector<T>) have been tried.The BCL itself uses unsafe techniques internally so application code doesn't have to. Note that <AllowUnsafeBlocks> also raises the bar in security reviews.
In an image-thumbnail service, a resize loop indexed pixels[y * width + x] with y and x from nested loops and width from an object field. A disassembly showed a bounds check on every pixel and no vectorisation. Rewriting it to take Span<Rgba32> row = pixels.AsSpan(y * width, width) per row and loop for (int x = 0; x < row.Length; x++) removed the inner checks; switching the inner operation to Vector<T> arithmetic gave another big gain. No unsafe was needed. The overall p95 latency for thumbnail generation dropped by about a third.
Probes runtime and JIT knowledge and performance judgement.
Green flags
Red flags
foreach over arrays is check-free.## 1. Where bounds checks happen
| Access | Checked? |
|---|---|
| `array[i]` (SZ array) | Yes — IL `ldelem`/`stelem`, JIT may remove |
| `array[i, j]` (MD array) | Yes, per dimension |
| `span[i]`, `readOnlySpan[i]` | Yes — JIT intrinsic, removable like arrays |
| `str[i]` | Yes — `string` indexer is an intrinsic |
| `list[i]` | Yes — `List
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Arrays;
public sealed class FrameDecoder(byte[] buffer)
{
private readonly byte[] _buffer = buffer;
// Checks likely remain: field access and offset arithmetic
public int ChecksumSlow(int pos, int length)
{
int sum = 0;
for (int k = 0; k < length; k++)
sum += _buffer[pos + k];
return sum;
}
// Safe and fast: validate once by slicing, then loop over the span's own length
public int ChecksumFast(int pos, int length)
{
ReadOnlySpan<byte> frame = _buffer.AsSpan(pos, length); // throws once if out of range
int sum = 0;
for (int k = 0; k < frame.Length; k++)
sum += frame[k]; // no bounds check
return sum;
}
// Last resort: no checks at all. Only after measuring, with explicit validation outside.
public int ChecksumUnsafe(int pos, int length)
{
if ((uint)pos > (uint)_buffer.Length || (uint)length > (uint)(_buffer.Length - pos))
throw new ArgumentOutOfRangeException(nameof(length));
ref byte start = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(_buffer), pos);
int sum = 0;
for (int k = 0; k < length; k++)
sum += Unsafe.Add(ref start, k);
return sum;
}
}
public static class BoundsDemo
{
public static void Main()
{
var decoder = new FrameDecoder([1, 2, 3, 4, 5, 6]);
Console.WriteLine(decoder.ChecksumSlow(1, 3)); // 9
Console.WriteLine(decoder.ChecksumFast(1, 3)); // 9
Console.WriteLine(decoder.ChecksumUnsafe(1, 3)); // 9
try { decoder.ChecksumFast(4, 5); }
catch (ArgumentOutOfRangeException) { Console.WriteLine("Rejected before reading memory"); }
int[] data = [3, 1, 4, 1, 5];
int total = 0;
foreach (var d in data) total += d; // check-free
Console.WriteLine(total);
}
}
unsafe by defaultWhat happens: pointer code added "for performance" without profiling. Why it is wrong: removes memory safety, risks corruption and vulnerabilities, and often gains nothing because the JIT already eliminated the checks. Do instead: measure, check disassembly, try safe idioms first.
Length in a variable or fieldWhat happens: int len = arr.Length; for (int i = 0; i < len; i++) or using a field for the length.
Why it is wrong: older advice; it can prevent the JIT recognising the canonical pattern, keeping the checks.
Do instead: write i < arr.Length (or loop over a span).
What happens: for (int i = 0; i < _items.Length; i++) _items[i]... where _items is a mutable field.
Why it is wrong: the JIT can't assume _items is the same array each iteration, so checks remain (and another thread could swap it).
Do instead: copy to a local first: var items = _items;.
IndexOutOfRangeException for control flowWhat happens: reading until an exception signals the end of data.
Why it is wrong: exceptions are expensive and hide real bugs.
Do instead: check lengths explicitly or use TryXxx patterns.
What happens: measuring loops with a Stopwatch in Debug builds.
Why it is wrong: Debug and tier-0 code keep all checks; results don't reflect production.
Do instead: use BenchmarkDotNet in Release.
What happens: IList<int> or IReadOnlyList<int> indexed in tight loops.
Why it is wrong: interface calls and per-call checks prevent elimination and inlining.
Do instead: use arrays/spans in hot internal code.
What happens: hand-written Vector<T> loops for sums, searches and copies.
Why it is wrong: duplicates heavily optimised BCL code with more risk of off-by-one errors in remainder handling.
Do instead: use TensorPrimitives, MemoryExtensions and SearchValues first.
What happens: switching to spans and expecting checks to disappear everywhere. Why it is wrong: spans are bounds-checked like arrays; the benefit comes from slicing and canonical loops. Do instead: slice once, loop canonically, verify with disassembly.
fixed broadlyWhat happens: large buffers pinned for long periods.
Why it is wrong: pinned objects prevent heap compaction, causing fragmentation and GC overhead.
Do instead: keep pinning short, or use MemoryMarshal/Unsafe refs which don't pin; use the Pinned Object Heap for long-lived pinned buffers.
What happens: unsafe parsers tested only with well-formed input. Why it is wrong: malformed or malicious input causes out-of-bounds reads/writes — security vulnerabilities. Do instead: fuzz test (e.g. SharpFuzz), test empty/short/huge inputs, and isolate unsafe code in small reviewed helpers.
Seniors treat bounds checks as part of the safety model and optimise them only with evidence.
| Situation | Approach |
|---|---|
| Normal business code | ignore; checks are negligible |
| Hot numeric or parsing loop | slice to spans, loop over own length, prefer vectorised BCL APIs |
| Still hot after that | inspect disassembly, try loop restructuring |
| Proven bottleneck, reviewed | small Unsafe helper with explicit validation and fuzz tests |
cmp/jae/RNGCHKFAIL) is a strong senior performance skill.ReadOnlySpan<char> is a view over contiguous characters, which can be a string, an array or a stack buffer, made of a reference plus a length. Slicing it is free (no new string), and almost every parsing API now takes spans, so I can split a CSV line, trim fields and parse numbers and dates with zero allocations until I really need a string. It's a ref struct, so it can't be stored in a class field, boxed, captured by a lambda or held across an await. For those cases I use ReadOnlyMemory<char> and call .Span when processing. I reach for it in measured hot paths such as parsers, protocol code and high-volume ingestion.
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.
How to tell it (STAR)
**S:** Our market-data gateway [at company] could not keep up with peak message rates. **T:** I was asked to improve decoding throughput safely. **A:** I benchmarked the decoder, read the JIT assembly to find a bounds check and field reload per byte, and restructured it to slice each frame into a span and loop over its length. **R:** Throughput rose by about [35%] with no unsafe code, and the benchmark guards it in CI.
Last updated 30 Oct 2026.