Span and Memory · How it works

6 min read
Senior11 min read
Rapid overview

How it works

What a span is

Q: What is Span<T>?

A: A type (C# 7.2, .NET Core 2.1) that represents a contiguous region of memory of Ts without owning it. It is essentially (ref T start, int length): a managed pointer to the first element and a count. It offers indexing (returning ref T, so you can write through it), slicing, CopyTo, Fill, IndexOf, sorting and more, with bounds checks. ReadOnlySpan<T> is the read-only version, used for strings and constant data.

int[] numbers = { 1, 2, 3, 4, 5, 6 };
Span<int> middle = numbers.AsSpan(1, 4);   // view over 2, 3, 4, 5 - no copy
middle[0] = 20;                            // writes into the array
Console.WriteLine(numbers[1]);             // 20

ReadOnlySpan<char> text = "order-12345";
ReadOnlySpan<char> id = text[6..];         // "12345" - no new string
int value = int.Parse(id);                 // parse straight from the span
  • managed arrays: new byte[1024].AsSpan(), or implicit conversion from T[];
  • strings: "abc".AsSpan() gives a ReadOnlySpan<char> over the string's characters;
  • the stack: Span<byte> buf = stackalloc byte[256]; (safe code, no unsafe needed when assigned to a span);
  • native memory: new Span<byte>(pointer, length) over memory from NativeMemory.Alloc or an interop call;
  • lists and other buffers: CollectionsMarshal.AsSpan(list) (do not add or remove items while using it).
Q: What kinds of memory can a span point to?

A: Any contiguous memory:

The consuming code does not care which: one Parse(ReadOnlySpan<byte>) method handles all of them.


Memory level

Q: How is a span laid out, and why is it cheap?

A: It is two fields on the stack: a managed reference (ref T, the kind of pointer the GC understands, possibly pointing into the middle of an object) and an int length. Creating, slicing and passing a span copies those 16 bytes and nothing else: the data is never copied. Slicing just adjusts the pointer and length.

 stack                                       managed heap
┌──────────────────────────┐                ┌─────────────────────────────────────┐
│ Span<int> middle         │                │ int[] header | MT | length 6        │
│  _reference ─────────────┼──────────┐     │ [0]=1 [1]=20 [2]=3 [3]=4 [4]=5 [5]=6│
│  _length = 4             │          └────►│        ▲                            │
└──────────────────────────┘                └────────┼────────────────────────────┘
                                                     interior pointer to element 1
  • it cannot be a field of a class or a normal struct (only of another ref struct);
  • it cannot be boxed, cast to object or an interface, or be an array element;
  • it cannot be captured by a lambda or local function that becomes a delegate;
  • it cannot be a local that lives across an await or yield return (those locals move to the heap);
  • it could not be a generic type argument until C# 13's allows ref struct anti-constraint.
Q: Why must a span stay on the stack (why is it a ref struct)?

A: Two reasons. It may point at stack memory (stackalloc), which disappears when the method returns; if a span could be stored on the heap it could outlive that memory and read garbage. And it holds an interior pointer, which the GC can track cheaply only while it lives in a stack frame or register, not in heap objects. So the compiler enforces that a ref struct never ends up on the heap:

Q: What is Memory<T> and when do you use it instead?

A: Memory<T> (and ReadOnlyMemory<T>) describes the same kind of region but is a normal struct that can live on the heap: in fields, in closures, across await. It cannot point to stack memory. You keep a Memory<T> for as long as you need, and get a Span<T> from .Span only for the synchronous piece of work:

public async Task<int> ReadHeaderAsync(Stream stream, Memory<byte> buffer, CancellationToken ct)
{
    int read = await stream.ReadAsync(buffer, ct);   // Memory<T> survives the await
    return ParseHeader(buffer.Span[..read]);         // Span<T> used synchronously
}

static int ParseHeader(ReadOnlySpan<byte> data) => BinaryPrimitives.ReadInt32BigEndian(data);

Rule of thumb: Span<T> for synchronous methods and parameters; Memory<T> for storage and async.


Using spans to avoid allocations

Q: How does span-based parsing avoid allocations?

A: Instead of cutting a string into new strings (Substring, Split) and then parsing those, you slice views and parse directly from them. Most BCL parsing and formatting APIs have span overloads (int.Parse(ReadOnlySpan<char>), Guid.TryParse, DateTime.TryParseExact, Utf8Parser, Encoding.GetBytes(ReadOnlySpan<char>, Span<byte>)).

// Allocates: Split creates an array and three strings
static (int, int, int) ParseDate(string s)
{
    var parts = s.Split('-');
    return (int.Parse(parts[0]), int.Parse(parts[1]), int.Parse(parts[2]));
}

// Allocation-free
static (int, int, int) ParseDate(ReadOnlySpan<char> s) =>
    (int.Parse(s[..4]), int.Parse(s[5..7]), int.Parse(s[8..10]));
Q: When is stackalloc appropriate?

A: For small, short-lived buffers whose size is known and bounded, typically up to a few hundred bytes or around 1 KB. Stack space is limited (about 1 MB per thread by default), and a large or user-controlled size risks a StackOverflowException, which cannot be caught and kills the process. The standard pattern falls back to a pooled array:

const int MaxStack = 256;
byte[]? rented = null;
Span<byte> buffer = length <= MaxStack
    ? stackalloc byte[MaxStack]
    : (rented = ArrayPool<byte>.Shared.Rent(length));
try
{
    buffer = buffer[..length];
    Fill(buffer);
    Send(buffer);
}
finally
{
    if (rented is not null) ArrayPool<byte>.Shared.Return(rented);
}
Q: Where do spans appear in modern .NET APIs?

A: Everywhere performance matters: Stream.Read(Span<byte>), Socket APIs, System.Text.Json's Utf8JsonReader (a ref struct over ReadOnlySpan<byte>), string.Create, MemoryExtensions (AsSpan, IndexOfAny, Split into a span of ranges), SearchValues<T> for fast searching, UTF-8 string literals ("GET"u8 is a ReadOnlySpan<byte>), and params ReadOnlySpan<T> parameters (C# 13) that let calls like string.Concat("a", "b", "c") avoid allocating an array.


Safety rules

Q: Why can't a method return a span over its own stackalloc buffer?

A: The buffer is freed when the method returns, so the span would point at dead stack memory. The compiler's escape analysis tracks where each span's memory comes from ("safe-to-escape" scope) and rejects code that lets a span outlive its memory. Spans over arrays or parameters can be returned; spans over local stack memory cannot.

Span<int> Bad()
{
    Span<int> local = stackalloc int[4];
    return local;              // error CS8352: may expose referenced variables outside their scope
}

Span<int> Fine(int[] data) => data.AsSpan(1);   // array outlives the method
Q: What does the scoped keyword do?

A: (C# 11) It marks a span parameter or local as not allowed to escape the current method. That lets the compiler accept code it would otherwise reject, for example passing a stackalloc span into a method whose parameter is scoped Span<T>, because it knows the callee will not store it anywhere that outlives the call.

Q: Is a span thread-safe?

A: A span has no synchronization; it is just a view. Two spans over the same array see each other's writes, and concurrent writes race like any array access. Since spans cannot leave the stack, sharing across threads happens through the underlying array or Memory<T>, which you must coordinate yourself.


Common interview gotchas

Q: Span<T> vs T[] vs ArraySegment<T>?

A: T[] owns heap memory. ArraySegment<T> is an older view over part of an array only, usable on the heap. Span<T> is a view over any contiguous memory (array, string, stack, native), with a faster, richer API, but restricted to the stack. Memory<T> is the heap-storable counterpart of Span<T>.

Q: Why can't you use Span<T> in an async method?

A: You can use it between awaits, but a span local cannot be alive across an await, because locals that survive an await are moved into the heap-allocated state machine and spans must not live on the heap. Use Memory<T> across the await and take .Span afterwards.

Q: Does "hello".AsSpan() copy the string?

A: No. It returns a ReadOnlySpan<char> pointing at the string's existing characters. Only turning a span back into a string (span.ToString() or new string(span)) allocates.

Q: Why is iterating a span often faster than iterating a List<T>?

A: Indexing a span is a bounds-checked pointer offset, and the JIT can remove the bounds check in a for (int i = 0; i < span.Length; i++) loop. List<T> goes through its indexer (checking against Count and reading the internal array) or an enumerator with version checks. CollectionsMarshal.AsSpan(list) gives span speed over a list's backing array.