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:

  1. i starts at zero.

  2. 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:

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:

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:

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:

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.