When C# code reads an item from an array or a Span<T>, .NET has to make sure the index is valid. This is called a bounds check.
For example:
int value = numbers[index];
Before accessing the memory, the runtime needs to make sure that index is within the valid range.
This safety check is important because C# is a managed language. It prevents normal array and span access from reading memory outside the object or buffer.
At the same time, bounds checks can become interesting in performance-sensitive code. If a loop processes millions of values, repeatedly checking an index can potentially add overhead.
The .NET JIT compiler can analyze the code and remove bounds checks when it can prove that an access is safe.
This article explains how that works with arrays and spans, what has improved in modern .NET, and how developers can write loops that give the JIT a better opportunity to optimize them.
What Is a Bounds Check?
Consider a simple array:
int[] numbers = { 10, 20, 30, 40, 50 };
int value = numbers[2];
The valid indexes are:
0
1
2
3
4
If the application tries:
int value = numbers[5];
the access is invalid.
C# normally protects this operation by checking the index before accessing the array.
If an invalid index is used, an IndexOutOfRangeException is thrown.
The same basic principle applies when accessing elements through a Span<T>.
For example:
Span<int> numbers = stackalloc int[] { 10, 20, 30, 40, 50 };
int value = numbers[2];
The runtime and compiler infrastructure work together to provide safe access.
Why Bounds Checks Matter for Performance
For normal application code, bounds checks are rarely something you need to think about.
However, consider a loop processing a large buffer:
public static long Sum(int[] values)
{
long total = 0;
for (int i = 0; i < values.Length; i++)
{
total += values[i];
}
return total;
}
The loop may execute thousands or millions of times.
Conceptually, every array access needs to satisfy:
0 <= i < values.Length
If the JIT cannot prove that this condition is always true, it needs to preserve the safety check.
If the JIT can prove the condition from the loop structure, it can sometimes eliminate the redundant check.
That means the source code still has safe array access, but the generated machine code may avoid unnecessary work.
How the JIT Understands a Simple Loop
The following loop is easy for the JIT to reason about:
for (int i = 0; i < values.Length; i++)
{
total += values[i];
}
There are two important facts:
istarts at zero.The loop continues only while
i < values.Length.
Therefore, when values[i] is executed, the index is already known to be within the array's valid range.
This is the type of pattern where bounds-check elimination can be useful.
The exact generated machine code is determined by the runtime, target architecture, surrounding code, and other JIT decisions, so developers should not assume that every loop will be optimized in exactly the same way.
Arrays and Bounds Checks
Arrays are one of the most common places where bounds checks appear.
Consider:
public static void DoubleValues(int[] values)
{
for (int i = 0; i < values.Length; i++)
{
values[i] *= 2;
}
}
This is a simple and predictable access pattern.
Now compare it with code where the index is calculated:
public static void Process(int[] values)
{
for (int i = 0; i < values.Length / 2; i++)
{
int index = i * 2;
values[index] *= 2;
}
}
The second example requires more reasoning.
The JIT has to determine whether:
i * 2
can ever become greater than or equal to:
values.Length
depending on the exact loop structure.
This does not mean calculated indexes are bad. It simply means that code structure can affect how much information is available to the optimizer.
What Changes with Span?
Span<T> provides a way to work with contiguous memory while avoiding some common allocation patterns.
For example:
public static int Sum(ReadOnlySpan<int> values)
{
int total = 0;
for (int i = 0; i < values.Length; i++)
{
total += values[i];
}
return total;
}
This looks very similar to the array example.
That is intentional.
A span has a length and an indexable region of memory. Access through:
values[i]
must still be safe.
The JIT can use the loop's structure to reason about the valid range.
This is especially relevant for code that repeatedly processes buffers.
Examples include:
Parsing
Serialization
Encoding
Network protocols
File processing
Image processing
Data transformation
Array vs Span Bounds Checks
The programming model is similar, but the types have different purposes.
Feature | Array | Span |
|---|---|---|
Owns the underlying data | Yes | No |
Can represent part of an array | No, without creating another abstraction | Yes |
Can work with stack memory | No | Yes |
Allocation required for the span itself | Array allocation is required | Span itself does not require a heap allocation |
Index access is bounds-checked | Yes | Yes |
Useful for buffer processing | Yes | Yes |
Suitable for async fields | Yes | No, because Span is a ref struct |
The important point is that Span<T> does not mean "unchecked memory access."
It remains a safe abstraction.
Slicing and Bounds Checks
One useful feature of spans is slicing.
For example:
ReadOnlySpan<byte> data = buffer;
ReadOnlySpan<byte> header = data[..8];
ReadOnlySpan<byte> payload = data[8..];
The slice operations themselves need to ensure that the requested range is valid.
After a valid slice has been created, code can operate on that smaller region:
for (int i = 0; i < payload.Length; i++)
{
Process(payload[i]);
}
The JIT can reason about the length of the span being processed.
This makes span-based code useful for parsers and protocol implementations where a large buffer is divided into smaller logical sections.
A Practical Parsing Example
Suppose an application receives a byte buffer and needs to calculate a checksum.
A span-based implementation might look like this:
public static uint CalculateChecksum(ReadOnlySpan<byte> data)
{
uint checksum = 0;
for (int i = 0; i < data.Length; i++)
{
checksum += data[i];
}
return checksum;
}
There is no need to create a new array just to process the data.
The method accepts a view over existing memory.
The loop is also simple enough for the JIT to analyze.
This type of pattern is useful when the method is called frequently and processes a significant amount of data.
What Makes Bounds-Check Elimination Difficult?
Not every access can be proven safe at compile or JIT time.
For example:
public static int GetValue(int[] values, int index)
{
return values[index];
}
The method receives an arbitrary index.
Unless the caller or surrounding code provides enough information, the JIT cannot simply assume that the index is valid.
Another example is more complicated indexing:
for (int i = 0; i < values.Length; i++)
{
int index = CalculateIndex(i);
total += values[index];
}
If CalculateIndex can return any value, the JIT may not be able to prove that index is valid.
The application may need to retain the bounds check.
This is not a problem with the runtime. It is a necessary safety mechanism.
Nested Loops
Nested loops are common in data-processing applications.
For example:
public static long Sum(int[][] values)
{
long total = 0;
for (int i = 0; i < values.Length; i++)
{
int[] row = values[i];
for (int j = 0; j < row.Length; j++)
{
total += row[j];
}
}
return total;
}
Notice that the inner loop uses:
row.Length
and accesses:
row[j]
The relationship between the loop counter and the specific array being accessed is clear.
Keeping a reference to the row also makes the code easier to read.
Compare this with repeatedly indexing through the outer array:
for (int i = 0; i < values.Length; i++)
{
for (int j = 0; j < values[i].Length; j++)
{
total += values[i][j];
}
}
Both versions are valid, and you should not assume one will always produce faster machine code.
The first version, however, makes the data relationship explicit and can be easier for both developers and optimization tools to reason about.
The correct choice should ultimately be based on measurement.
Common Mistakes
Manually Removing Safety Checks
A common mistake is trying to avoid bounds checks by switching to unsafe pointers without first measuring the problem.
For example:
unsafe
{
fixed (int* ptr = values)
{
// Pointer-based processing
}
}
Unsafe code can be useful for specialized scenarios, but it should not be the default response to a suspected performance issue.
It introduces additional complexity and places more responsibility on the developer.
Assuming Span Means No Checks
Span<T> provides efficient memory access, but it does not mean that indexing becomes completely unchecked.
For example:
Span<int> values = stackalloc int[10];
int result = values[20];
The access is still invalid.
The safety model remains important.
Applying Old Micro-Optimizations
Developers sometimes write code based on performance advice from much older versions of .NET.
Modern JIT optimizations have changed considerably.
A manual optimization that helped years ago may provide no benefit today and can make the code harder to understand.
Best Practices
Use Simple Loop Conditions
Prefer clear patterns such as:
for (int i = 0; i < values.Length; i++)
{
Process(values[i]);
}
when they naturally fit the problem.
Avoid Unnecessary Work Inside Hot Loops
If a value can be calculated once outside a loop, do not repeatedly calculate it inside the loop without a reason.
Use Span for Appropriate Memory Workloads
Span<T> is useful when you need to work with existing contiguous memory without creating unnecessary temporary arrays.
Do not introduce spans into ordinary business logic just because they sound faster.
Measure Before and After
If bounds checks are suspected to be a performance issue, benchmark the real workload.
A small microbenchmark can help isolate the behavior:
using BenchmarkDotNet.Attributes;
public class ArrayBenchmark
{
private readonly int[] values = new int[100_000];
[Benchmark]
public long Sum()
{
long total = 0;
for (int i = 0; i < values.Length; i++)
{
total += values[i];
}
return total;
}
}
Run the benchmark using a Release configuration and compare the results with the runtime versions you are evaluating.
How to Investigate a Bounds-Check Performance Problem
If profiling shows that a loop is consuming a significant amount of CPU time, use a structured approach.
Step 1: Find the Hot Method
Do not start by changing array access everywhere.
First identify the method responsible for the CPU usage.
Step 2: Simplify the Loop
Look at the indexing pattern.
Ask:
Is the index directly related to the loop counter?
Is the collection length stable during the loop?
Are there unnecessary calculations?
Is there complicated control flow?
Step 3: Create a Focused Benchmark
Extract the relevant operation into a benchmark.
This helps separate the cost of the operation from database, network, logging, or other application activity.
Step 4: Compare Runtime Versions
Run the same benchmark using the runtime versions you want to compare.
Keep the hardware and benchmark inputs consistent.
Step 5: Check the Generated Code if Necessary
For low-level performance investigations, inspecting generated assembly can help determine whether bounds checks are actually present.
This should normally be a later step, not the first thing you do.
Troubleshooting
Performance Did Not Improve
Do not immediately assume the JIT is failing to optimize the code.
The bottleneck may be somewhere else.
Check:
Database calls
Network operations
Memory allocation
Garbage collection
Lock contention
File I/O
Serialization
Thread scheduling
Span Version Is Not Faster
That can be completely normal.
Span<T> solves specific memory-access and allocation problems. It is not a guarantee that replacing an array parameter with a span will improve every workload.
Results Differ Between Machines
JIT-generated code can depend on the target architecture and available CPU capabilities.
A benchmark run on one machine should not automatically be treated as representative of every production environment.
Advantages and Disadvantages
Advantages | Disadvantages |
|---|---|
Safe array and span access | Bounds checks still exist when safety cannot be proven |
JIT can eliminate unnecessary checks | Optimization depends on code structure |
Works automatically without special compiler flags | Exact generated code can vary by architecture |
Useful for high-throughput loops | Low-level tuning can make code harder to maintain |
Span enables efficient buffer processing | Span has restrictions because it is a ref struct |
When Should You Care About Bounds Checks?
For most applications, you probably do not need to think about them directly.
If your application spends most of its time doing this:
API request
|
v
Database query
|
v
Business logic
|
v
Response
then database and network latency may be much more important than a bounds check.
But if your application spends a large percentage of its CPU time processing memory in tight loops, bounds-check behavior can become relevant.
Examples include:
Parsing large files
Processing network packets
Serialization
Compression
Image processing
Numerical calculations
Large in-memory transformations
In these cases, small per-element costs can add up.
Summary
Bounds checks are an important part of the safety model in C#. They prevent invalid array and span accesses while allowing developers to work with memory without manually managing pointers.
The .NET JIT can analyze common loop patterns and, when it can prove that an access is safe, remove unnecessary bounds checks from generated code. Simple loops with predictable indexing give the optimizer more information to work with.
Span<T> provides another useful option for high-performance memory processing, but it does not remove the safety model or guarantee faster execution.
The practical approach is to keep code simple, use arrays and spans where they make sense, and measure real workloads before making low-level changes. When performance matters, profiling and benchmarking are much more reliable than assuming that a particular syntax or optimization must be faster.

Join the conversation! Your thoughts help the community grow.