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];The range operation creates a new array.
Compare that with:
ReadOnlySpan<int> view = source.AsSpan(1000, 4000);The second operation creates a span view rather than copying the elements into another array.
This distinction can matter enormously inside high-throughput applications.
For example,
JSON parsing
HTTP processing
binary protocols
serialization
image processing
log processing
file parsing
network buffers
These workloads can process millions of small pieces of data, making unnecessary allocations expensive.
Span Does Not Own Memory
One of the most important concepts developers need to understand is that Span<T> isn't an alternative collection in the same sense as List<T>.
It is better to think of it as a window over memory.
For example:
int[] numbers = { 10, 20, 30, 40, 50 };
Span<int> span = numbers.AsSpan(1, 3);
span[0] = 200;The original array changes:
Console.WriteLine(numbers[1]);Output:
200The span didn't create an independent copy.
It referenced the same memory.
This is one reason spans are powerful for high-performance APIs, but it is also why developers need to understand their lifetime and mutation behavior.
Array vs List vs Span
Here's the practical comparison.
Feature | Array | List | Span |
|---|---|---|---|
Fixed size | Yes | No | View only |
Dynamic resizing | No | Yes | No |
Owns storage | Yes | Yes | No |
Can avoid copying | Sometimes | Sometimes | Yes |
Stack-friendly | No | No | Yes |
Works over arrays | Natively | N/A | Yes |
Works over list storage | N/A | Natively | Via |
Allocation-free view | No | No | Yes |
Best use | Fixed collections | Dynamic collections | Hot-path memory access |
The key takeaway is that these aren't competing abstractions in every scenario.
They solve different problems.
When Should You Use Array?
Use an array when:
The collection size is known.
You need simple indexed access.
You own the data.
The data needs to live on the managed heap.
You don't need dynamic resizing.
Example:
byte[] buffer = new byte[4096];Arrays are also an excellent input for span-based APIs:
Process(buffer.AsSpan());This allows an API to accept a span without forcing callers to copy their arrays.
When Should You Use List?
List<T> is still the right choice for many ordinary application scenarios.
Use it when:
Elements need to be added or removed.
Collection size changes dynamically.
Developer productivity matters more than micro-optimization.
You need normal collection APIs.
You don't have a demonstrated hot-path performance problem.
Example:
var users = new List<User>();
users.Add(user);
users.Remove(user);Replacing every List<T> with a span would be a design mistake.
A span cannot replace the dynamic collection behavior that makes List<T> useful.
When Should You Use Span?
Span<T> becomes valuable when you need:
Low-allocation processing
Array slicing
Buffer manipulation
Parsing
Memory-efficient APIs
High-frequency loops
Stack-based temporary storage
Zero-copy processing
For example:
static int ParseFirstFourDigits(ReadOnlySpan<char> value)
{
return int.Parse(value[..4]);
}The method can operate directly on a portion of existing character data rather than requiring a new string.
This style is particularly valuable in parsers and high-throughput infrastructure.
A More Interesting .NET 10 Benchmark
To test .NET 10 specifically, BenchmarkDotNet can compare multiple runtimes.
For example:
dotnet run -c Release \
--runtimes net9.0 net10.0Your benchmark should then report separate results for each runtime.
This is more useful than simply asking which collection is faster because it demonstrates how the runtime itself affects the generated machine code.
Microsoft's own .NET 10 performance investigation uses BenchmarkDotNet and reports improvements across areas including JIT optimization, spans, allocations, and collection processing.
Don't Optimize Based on Assumptions
One of the biggest mistakes in C# performance engineering is assuming:
Span is always faster.
That isn't true.
For a simple loop:
for (int i = 0; i < array.Length; i++)
{
sum += array[i];
}an array can already be highly optimized by the JIT.
.NET 10 has specifically improved array interface devirtualization and array iteration optimization.
The right question isn't:
Which type is fastest?
It is:
Which representation produces the least unnecessary work for this workload?
That distinction matters.
Practical Optimization Strategy
A good progression for production C# code is:
Step 1: Start with the simplest correct data structure
Use:
List<T>when you need a dynamic collection.
Use:
T[]when you need fixed-size storage.
Step 2: Profile
Identify the actual hot path.
Step 3: Benchmark
Use BenchmarkDotNet rather than intuition.
Step 4: Reduce allocations
Look for:
unnecessary arrays
string creation
LINQ allocations
temporary objects
repeated conversions
unnecessary copies
Step 5: Introduce Span
Use spans where they solve a demonstrated performance problem.
Step 6: Re-run the benchmark
Optimization isn't complete until the benchmark demonstrates an improvement.
Final Verdict
There is no universal winner in the Span<T> vs array vs List<T> performance battle.
For straightforward sequential processing, arrays and spans can be extremely close, because a span may simply be a view over the same underlying array.
For dynamic collections, List<T> remains the practical choice.
For slicing, parsing, buffer manipulation, and allocation-sensitive hot paths, Span<T> becomes particularly powerful because it can provide a view over existing memory without copying the underlying elements.
.NET 10 makes this area even more compelling. Its JIT improvements extend optimization opportunities for spans, arrays, collections, and generated machine code, while C# 14 adds more natural span conversions.
The best performance strategy is therefore not to replace every collection with Span<T>. Instead, benchmark the actual workload, understand where allocations and bounds checks occur, and use the lowest-overhead representation that fits the ownership and lifetime requirements of the data.
Summary
For developers working on parsers, serializers, networking, high-throughput APIs, or other performance-critical .NET applications, that distinction can be far more important than the raw benchmark number from a single micro-test.

Join the conversation! Your thoughts help the community grow.