Span<T> is a type that represents a contiguous region of arbitrary memory - an array, a slice of an array, a string, stack-allocated memory, or native memory - without copying it. It is the modern answer to "pass a pointer and a length" and underpins most of the performance work in .NET since C# 7.2.
The catch is that a Span<T> is a ref struct C# 7.2, which means it lives only on the stack. Understanding why that restriction exists, and when Memory<T> is the escape hatch, is most of the learning curve.
Why Span exists
Before spans, a method that wanted to work on part of an array had three choices: take an offset and length as separate parameters, copy the slice into a new array, or use unsafe pointers.
// Before: offset and length, easy to get wrong
static int Sum(int[] values, int start, int count)
{
var total = 0;
for (var i = start; i < start + count; i++)
total += values[i];
return total;
}
// With a span: the slice is the parameter
static int Sum(ReadOnlySpan<int> values)
{
var total = 0;
foreach (var value in values) total += value;
return total;
}The span version cannot get the bounds wrong because the slice carries its own length, and it allocates nothing.
One type, many sources
The same ReadOnlySpan<char> can come from a string, an array, stack memory, or native memory:
var text = "hello world";
ReadOnlySpan<char> fromString = text.AsSpan(6); // "world"
ReadOnlySpan<char> fromLiteral = "world"; // implicit conversion
char[] array = { 'w', 'o', 'r', 'l', 'd' };
ReadOnlySpan<char> fromArray = array;
Span<byte> fromStack = stackalloc byte[8];This is why library APIs increasingly accept spans: one signature serves every caller without forcing an allocation.
Slicing is a view, not a copy
Ranges on a span return another span over the same memory. Writing through the slice writes through to the original:
var numbers = new[] { 1, 2, 3, 4, 5 };
Span<int> slice = numbers.AsSpan()[1..4]; // elements 1, 2, 3
slice[0] = 99;
// numbers is now 1, 99, 3, 4, 5Ranges and indices arrived with ranges C# 8.0 and work on any type with a Slice method, which includes Span<T> and ReadOnlySpan<T>.
The ref struct restriction
A Span<T> holds a managed pointer to memory that may be on the stack. If it could be stored on the heap, the stack frame it points into could be gone while the span still referred to it. The compiler prevents that by making Span<T> a ref struct, which cannot:
- Be a field of a class (it can be a field of another
ref struct) - Be boxed to
objector to an interface - Be captured by a lambda or local function closure
- Survive an
awaitoryieldboundary
That last restriction is the one that bites in practice:
async Task<int> CountAsync(int[] data)
{
Span<int> span = data;
await Task.Yield();
return span.Length; // error CS4007: cannot be preserved across 'await'
}The fix is to re-derive the span after the await, or to use Memory<T>.
Memory<T> as the escape hatch
Memory<T> is a heap-safe counterpart to Span<T>. It can be stored in a field, captured, and survive an await; you call .Span to get a span for the duration of a synchronous operation.
public sealed class Buffer
{
private readonly Memory<byte> _memory; // can be a field
public Buffer(Memory<byte> memory) => _memory = memory;
public async Task ProcessAsync()
{
await Task.Yield();
Process(_memory.Span); // span created after the await
}
private static void Process(ReadOnlySpan<byte> data) { }
}Use Memory<T> when the data must outlive a method or cross an await, and Span<T> for the actual work. Every Memory<T> can produce a Span<T>, but not the other way round.
stackalloc and inline arrays
stackalloc allocates a block on the stack, which is ideal for small, short-lived buffers:
Span<byte> buffer = stackalloc byte[256];Stack space is limited (typically around 1MB per thread), so this is only appropriate for small buffers. A stackalloc in a loop allocates once at method entry, not per iteration. Since C# 8 it can be used as an argument or inside a conditional - see stackalloc in nested expressions C# 8.0.
For a fixed-size buffer that needs to be a field of a struct rather than a local, inline arrays C# 12.0 give the same embedded storage without unsafe.
Pooling instead of allocating
When a buffer is too large for the stack but still short-lived, rent it from ArrayPool<T> and return it in a finally. The pool amortises the allocation across calls.
const int Size = 4096;
var rented = ArrayPool<byte>.Shared.Rent(Size);
try
{
var buffer = rented.AsMemory(0, Size); // always re-slice to what you asked for
var read = await stream.ReadAsync(buffer);
Process(buffer.Span[..read]);
}
finally
{
ArrayPool<byte>.Shared.Return(rented);
}Note that Rent may return a larger array than requested, so always re-slice to the size you asked for. Forgetting the Return, or returning an array you still hold a span over, is the usual source of bugs here.
Practical tips
- Accept
ReadOnlySpan<T>in APIs, notT[]. The caller can still pass an array, and every other source becomes possible without a copy. - Use
ReadOnlySpan<char>for parsing. String slicing allocates; span slicing does not. Combined with pattern match on spans C# 11.0 and UTF-8 literals C# 11.0, a parser can run allocation-free. - Remember that spans do not own memory. A
Span<T>over a rented buffer is invalid the moment the buffer is returned, and the compiler will not catch that. - Prefer
Memory<T>at API boundaries that cross an await, and convert to a span for the synchronous work. - Do not use spans to hide a copy. If the data is being materialised anyway, a plain
T[]orIReadOnlyList<T>is simpler and the span gives nothing back.