Performance optimization in C# often comes down to choosing the right data structure. T[], List<T>, and Span<T> can all represent sequences of values, but they differ greatly in allocation, memory access, slicing, and iteration.
With .NET 10, this comparison becomes even more interesting. The runtime includes improvements to JIT optimization, loop cloning, devirtualization, stack allocation, and Span<T> handling. Microsoft specifically notes that more code is being written around spans and that .NET 10 extends important JIT optimizations to span-based loops.
This article builds a practical BenchmarkDotNet test to compare arrays, lists, and spans and explains when each approach makes sense.
Why Compare Span, Array, and List?
At first glance, these types appear interchangeable:
int[] array = new int[1000];
List<int> list = new List<int>(1000);
Span<int> span = array;All three allow indexed access:
value = array[index];
value = list[index];
value = span[index];But their underlying behavior is different.
An array is a fixed-size managed object with contiguous elements.
List<T> is a dynamically sized collection backed internally by an array.
Span<T> is a lightweight ref struct representing a contiguous region of memory. It can provide a view over an array without creating another collection.
That distinction becomes important in hot loops, parsers, serializers, networking code, image processing, and other performance-sensitive workloads.
What Changed With .NET 10?
.NET 10 is an LTS release and introduces several runtime performance improvements, including better JIT code generation, devirtualization, stack allocation, and loop optimizations.
One particularly relevant improvement is that JIT loop cloning can now apply more effectively to span-based code.
Consider:
static int Sum(Span<int> values)
{
int total = 0;
for (int i = 0; i < values.Length; i++)
{
total += values[i];
}
return total;
}The runtime can optimize this kind of loop aggressively.
Microsoft's .NET 10 performance work specifically demonstrates improvements around span loops and bounds-check optimization.
C# 14 also introduces first-class span conversions, making interactions between arrays, Span<T>, and ReadOnlySpan<T> more natural.
Benchmark Setup
To make the comparison meaningful, use BenchmarkDotNet rather than relying on Stopwatch.
Create a console application:
dotnet new console -n SpanPerformance
cd SpanPerformance
dotnet add package BenchmarkDotNetThen use the following benchmark:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
namespace SpanPerformance;
[MemoryDiagnoser]
public class CollectionBenchmarks
{
private int[] _array = null!;
private List<int> _list = null!;
[GlobalSetup]
public void Setup()
{
_array = Enumerable.Range(0, 10_000).ToArray();
_list = _array.ToList();
}
[Benchmark]
public int ArrayLoop()
{
int sum = 0;
for (int i = 0; i < _array.Length; i++)
{
sum += _array[i];
}
return sum;
}
[Benchmark]
public int ListLoop()
{
int sum = 0;
for (int i = 0; i < _list.Count; i++)
{
sum += _list[i];
}
return sum;
}
[Benchmark]
public int SpanLoop()
{
int sum = 0;
Span<int> span = _array;
for (int i = 0; i < span.Length; i++)
{
sum += span[i];
}
return sum;
}
}Run the benchmark in Release mode:
dotnet run -c ReleaseBenchmarkDotNet executes multiple iterations and reports statistics such as mean execution time, error, standard deviation, and memory allocation.
Do not treat a single Stopwatch measurement as a reliable benchmark. JIT compilation, CPU frequency changes, garbage collection, OS scheduling, and other processes can distort short measurements.
Benchmark 1: Sequential Iteration
The first test asks a simple question:
How efficiently can each type be traversed?
The three implementations are conceptually equivalent:
for (int i = 0; i < array.Length; i++)
{
sum += array[i];
}for (int i = 0; i < list.Count; i++)
{
sum += list[i];
}Span<int> span = array;
for (int i = 0; i < span.Length; i++)
{
sum += span[i];
}For this workload, you should generally expect array and span performance to be very close, particularly when the span is simply a view over the same array.
The important point is that Span<T> isn't automatically a faster replacement for every array.
If the underlying data is already an array and your loop is straightforward, the JIT can optimize array access extremely well.
.NET 10 further improves these optimizations, including bounds-check handling and loop cloning for spans.
Benchmark 2: Slicing Without Allocation
This is where Span<T> becomes more interesting.
Suppose you only need elements 2,000 through 5,000 from an array.
A traditional approach might create a new array:
int[] subset = new int[3000];
Array.Copy(
_array,
2000,
subset,
0,
3000);That creates additional storage and copies the data.
With a span:
Span<int> subset = _array.AsSpan(2000, 3000);No new element array is created.
The span simply represents a view over the existing memory.
You can then process it:
int sum = 0;
foreach (int value in subset)
{
sum += value;
}This is one of the strongest practical use cases for Span<T>.
Instead of:
Original array
↓
Copy data
↓
New array
↓
Processyou can use:
Original array
↓
Span view
↓
ProcessThat can reduce both allocation and copying.
Benchmark 3: List and Span
An interesting optimization appears when the source data is a List<T>.
Modern .NET provides:
CollectionsMarshal.AsSpan(list)This exposes a span over the list's backing storage. Microsoft documents this API as returning a Span<T> view over the list's data.
Example:
using System.Runtime.InteropServices;
Span<int> span = CollectionsMarshal.AsSpan(_list);
int sum = 0;
for (int i = 0; i < span.Length; i++)
{
sum += span[i];
}This can eliminate some abstraction overhead in performance-critical code.
However, there is an important safety rule.
You should not add or remove elements from the list while the span is being used. Microsoft explicitly documents this restriction for CollectionsMarshal.AsSpan.
Therefore, this technique should be reserved for controlled hot paths rather than becoming the default way you work with every List<T>.
Also Read : .NET 11 and AI: What& New for Enterprise Developers in 2026
Benchmark 4: Allocation Matters
Execution time isn't the only metric.
Use:
[MemoryDiagnoser]in BenchmarkDotNet to track allocations.
For example, consider this:
int[] source = GetData();
int[] copy = source[1000..5000];
Join the conversation! Your thoughts help the community grow.