Span<T> is a ref struct wrapping a pointer and a length, giving you a type-safe, bounds-checked view over contiguous memory — array, stack, or unmanaged. It does not own the memory and does not allocate a copy of it. Reading and writing through a span reads and writes through to the underlying storage.
int[] source = { 1, 2, 3, 4, 5 };
Span<int> view = source.AsSpan(1, 3); // { 2, 3, 4 }, no copy
view[0] = 99;
// source is now { 1, 99, 3, 4, 5 }
The three sources a span can wrap:
int[], string (via AsSpan()), any T[]stackalloc blocksMemoryMarshalSpan<T> is stack-only, the JIT guarantees no GC-relocating operation can happen while a span referencing movable memory is live across it — that's the entire reason for the ref struct restriction, not an incidental side effect.
Four related types cover the mutable/read-only and stack/heap axes:
| Type | Mutability | Storage | Heap-storable | Typical use |
|---|---|---|---|---|
Span<T> | read/write | stack, array, unmanaged | no | synchronous hot-path parameter |
ReadOnlySpan<T> | read-only | stack, array, unmanaged, string data | no | parsing, string slicing |
Memory<T> | read/write | array, unmanaged only | yes | async methods, fields, closures |
ReadOnlyMemory<T> | read-only | array, unmanaged only | yes | async parsing, cached buffers |
Memory<T> is the heap-storable counterpart: it can live in a field, cross an await, or be captured in a lambda, and you call .Span on it to get a Span<T> for the synchronous portion of work. It cannot wrap stackalloc memory — stack memory doesn't survive long enough to justify a heap-storable handle to it.
Span<T> is declared ref struct. That single modifier is what makes it safe to hand out pointers to stack and GC-tracked memory without a lifetime-tracking runtime — the compiler enforces the lifetime instead, by disallowing anything that would let a span escape its stack frame.
| Operation | Allowed | Why |
|---|---|---|
| Local variable | yes | lifetime is the stack frame |
| Method parameter / return | yes | tracked via ref lifetime rules |
| Instance field of a class | no | class instances are heap objects with unbounded lifetime |
| Instance field of a non-ref struct | no | that struct could itself be boxed or heap-stored |
| Generic type argument | no | generics may be reified for reference types (pre-C# 13 allows ref struct) |
| Captured in a lambda / local function closure | no | closures are compiled to heap-allocated display classes |
Used across an await | no | the compiler-generated state machine is a heap object |
Used in an iterator (yield return) | no | iterators also compile to heap-allocated state machines |
Boxed to object | no | boxing is heap allocation by definition |
allows ref struct as a generic type parameter constraint, which lets generic code accept ref structs including Span<T>. It does not lift the field, closure, or async restrictions — it only widens what generic APIs can accept as a type argument.
Slicing is the operation span-first code leans on constantly. It produces a new Span<T> over a subrange of the same backing memory — pointer arithmetic and a length, nothing copied.
ReadOnlySpan<char> text = "2026-07-23T14:05:00Z";
ReadOnlySpan<char> datePart = text[..10]; // "2026-07-23"
ReadOnlySpan<char> timePart = text[11..19]; // "14:05:00"
ReadOnlySpan<char> year = text.Slice(0, 4); // "2026"
Range and index syntax ([..10], [^4..]) works on spans the same way it works on arrays, and lowers directly to Slice calls. Both throw ArgumentOutOfRangeException on an invalid range — bounds checking is retained, it's just performed once rather than per-element.
stackalloc paired with Span<T> gives you a stack-allocated buffer with array-like indexing and no GC pressure, for buffers that are small, short-lived, and don't need to outlive the current method.
Span<byte> buffer = stackalloc byte[256];
int written = EncodeHeader(request, buffer);
socket.Send(buffer[..written]);
stackalloc span is only valid for the duration of the current stack frame. Returning it, storing it in a field, or otherwise letting it escape the method produces a dangling reference — the compiler blocks the obvious cases (return, field assignment) because of the ref struct rules above, but passing it into a method that itself stashes a reference beyond the call is still your responsibility to avoid.
There's no hard rule for a size ceiling, but conventional guidance is to keep stackalloc under roughly 1–2 KB and avoid it entirely in code that recurses, since stack space is finite and there's no exception if you blow it — you get a hard StackOverflowException or process crash. For anything size-dependent on input, use ArrayPool<T>.Shared instead and rent a heap buffer with a bound.
Parsing is where span-first pays off most visibly: string-based parsing allocates a new string for every substring, split segment, or trim result. Span-based parsing walks indices over the original buffer and only materializes a string (or number) at the point of final consumption.
string line = "2026-07-23,LEWISVILLE,72.4,SUNNY";
string[] parts = line.Split(',');
string date = parts[0];
double temp = double.Parse(parts[2]);
ReadOnlySpan<char> line = "2026-07-23,LEWISVILLE,72.4,SUNNY";
ReadOnlySpan<char> remaining = line;
int comma = remaining.IndexOf(',');
ReadOnlySpan<char> date = remaining[..comma];
remaining = remaining[(comma + 1)..];
comma = remaining.IndexOf(',');
ReadOnlySpan<char> city = remaining[..comma];
remaining = remaining[(comma + 1)..];
comma = remaining.IndexOf(',');
double temp = double.Parse(remaining[..comma]);
Two things make this work without allocating: double.Parse, int.Parse, and the rest of the numeric parsing surface have ReadOnlySpan<char> overloads since .NET Core 3.0, and IndexOf/slicing on a span never allocate. The only allocation left is date and city if and when you actually call .ToString() on them — deferred to the point where you genuinely need a heap string.
SearchValues<T> (.NET 8+) precomputes an efficient lookup for repeated IndexOfAny calls against a fixed character or byte set — for tokenizers and delimiter scanning called in a loop, it outperforms a plain IndexOfAny(ReadOnlySpan<char>) call by avoiding repeated setup.
Span-first as an API design principle means the canonical overload takes a ReadOnlySpan<T>, and array- or string-accepting overloads are thin convenience wrappers over it — not the other way around.
public static class Checksum
{
// Canonical implementation: works over any contiguous memory
public static uint Compute(ReadOnlySpan<byte> data)
{
uint hash = 2166136261;
foreach (byte b in data)
hash = (hash ^ b) * 16777619;
return hash;
}
// Convenience overload: array falls through to the span version implicitly
public static uint Compute(byte[] data) => Compute(data.AsSpan());
}
This inverts the more common instinct, which is to write the array-based version first and bolt on a span overload later as an "optimization." Starting from the span means the array overload is free — byte[] converts to Span<byte> implicitly — and callers who already have a Memory<byte>, a stackalloc buffer, or a slice of a larger buffer get the zero-copy path without a second implementation to maintain.
ReadOnlySpan<char> accepts a string argument for free via the implicit conversion, but also accepts a slice of a larger string via AsSpan(start, length) — something a string-only parameter can never do without an intermediate Substring allocation.
System.Runtime.InteropServices.MemoryMarshal is the escape hatch for reinterpreting spans across type boundaries — reading a struct out of a byte buffer, or getting a raw reference for pointer-based interop, without a copy.
struct SensorReading
{
public float Temperature;
public float Humidity;
public long TimestampUnixMs;
}
// Reinterpret a byte buffer as a span of structs, no copy
ReadOnlySpan<byte> raw = socketBuffer[..payloadLength];
ReadOnlySpan<SensorReading> readings =
MemoryMarshal.Cast<byte, SensorReading>(raw);
MemoryMarshal.Cast<TFrom, TTo> reinterprets the underlying bytes directly — it requires both types to be unmanaged (no references) and the source length to be a whole multiple of the target element size, or it throws. MemoryMarshal.GetReference and MemoryMarshal.CreateSpan go a level lower, handing you (or constructing a span from) a raw managed reference for P/Invoke boundaries and fixed-size buffer scenarios.
MemoryMarshal.Cast and CreateSpan bypass normal type safety. Struct layout, padding, and endianness are not validated for you — a struct without [StructLayout(LayoutKind.Sequential)] (or Explicit) has a compiler-chosen field order, and casting bytes onto it assumes a layout you haven't actually pinned down.
The most common span-first failure mode is reaching for Span<T> in a method that also needs to await. It can't — a span can't be a field of the compiler-generated state machine, and it can't be reconstructed after an await because the operation it was viewing may have completed on a different logical continuation. The fix is a layered design: Memory<T> at the async layer, Span<T> at the synchronous leaf.
// Won't compile: Span<byte> can't be held across an await
async Task ProcessAsync(Span<byte> buffer)
{
await stream.FlushAsync();
Transform(buffer); // CS4012: parameter can't be used across await
}
// Works: Memory<byte> crosses the await, .Span is taken after
async Task ProcessAsync(Memory<byte> buffer)
{
await stream.FlushAsync();
Transform(buffer.Span); // fine — synchronous, span is scoped to this call
}
Framework I/O follows this pattern throughout: Stream.ReadAsync(Memory<byte>) is the async entry point, and the synchronous parsing or transform code operating on the result takes ReadOnlySpan<byte>. Design your own layered APIs the same way rather than forcing one type to do both jobs.
Representative BenchmarkDotNet-style figures for parsing a 40-character CSV row 1,000,000 times, string-first vs span-first. Exact numbers vary by machine and .NET version; the ratio is the point.
| Approach | Mean time | Allocated / op | Gen0 collections |
|---|---|---|---|
string.Split(',') + Parse | ~410 ns | ~192 B | high |
ReadOnlySpan<char> + manual scan | ~95 ns | 0 B | none |
The gap compounds under load: zero allocations per parse means zero Gen0 pressure, which means the collector never has to pause a hot request path to reclaim discarded split arrays and substrings. On a low-latency ingestion path processing tens of thousands of lines per second, that difference shows up directly as p99 latency rather than as a microbenchmark curiosity.
Span<byte> GetBuffer()
{
Span<byte> local = stackalloc byte[64];
return local; // compile error: CS8352 — escapes the method
}
The compiler catches the direct case. It does not catch every indirect one — passing a stackalloc span into a method that stores it in a ref field (permitted since C# 11's ref fields in ref structs) can still smuggle a dangling reference out if that storing method doesn't itself carry correct lifetime annotations.
Slice and the range operators validate start and length against the source span's bounds on every call — that check isn't eliminated, only moved from per-element to per-slice. It's cheap, not free. In a loop that re-slices on every iteration, that's still O(n) bounds checks, just a much smaller constant than per-element indexing with individual checks.
var cache = new Dictionary<string, int>();
ReadOnlySpan<char> key = line[..8];
cache[key]; // CS1503 — no implicit conversion, must ToString() first
Dictionary<string, T> can't be keyed directly by a span in older APIs. .NET 9 added Dictionary<TKey, TValue>.GetAlternateLookup<TAlternateKey>() specifically to allow a ReadOnlySpan<char>-keyed lookup against a string-keyed dictionary without an intermediate allocation — use it instead of the older workaround of maintaining a parallel string-keyed cache.
Span<T> has no IEnumerable<T> implementation — a ref struct can't implement interfaces that would require boxing it — so none of LINQ's extension methods apply. Span<T> ships its own hand-written equivalents (ToArray, and iteration via foreach against its own Enumerator struct), but .Where(), .Select(), and friends are unavailable and have to be hand-rolled as explicit loops.
Memory<T>/Span<T> layering split adds a second method and a seam that may not be worth it for a cold path.string/array-based method that's easier to read is the right default; profile before converting it.