Span-First C#: Designing Around Span
">
What Span actually is
Span 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 };<br>Spanint> view = source.AsSpan(1, 3); // { 2, 3, 4 }, no copy<br>view[0] = 99;<br>// source is now { 1, 99, 3, 4, 5 }
The three sources a span can wrap:
Managed arrays — int[], string (via AsSpan()), any T[]
Stack memory — stackalloc blocks
Unmanaged memory — pointers from native interop, wrapped via MemoryMarshal
Note<br>A span over a managed array does not pin it. The GC can still move the underlying array between accesses in general, but because Span 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.
The Span/Memory type hierarchy
Four related types cover the mutable/read-only and stack/heap axes:
TypeMutabilityStorageHeap-storableTypical use
Spanread/writestack, array, unmanagednosynchronous hot-path parameter<br>ReadOnlySpanread-onlystack, array, unmanaged, string datanoparsing, string slicing<br>Memoryread/writearray, unmanaged onlyyesasync methods, fields, closures<br>ReadOnlyMemoryread-onlyarray, unmanaged onlyyesasync parsing, cached buffers
Memory 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 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.
Ref struct constraints
Span 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.
OperationAllowedWhy
Local variableyeslifetime is the stack frame<br>Method parameter / returnyestracked via ref lifetime rules<br>Instance field of a classnoclass instances are heap objects with unbounded lifetime<br>Instance field of a non-ref structnothat struct could itself be boxed or heap-stored<br>Generic type argumentnogenerics may be reified for reference types (pre-C# 13 allows ref struct)<br>Captured in a lambda / local function closurenoclosures are compiled to heap-allocated display classes<br>Used across an awaitnothe compiler-generated state machine is a heap object<br>Used in an iterator (yield return)noiterators also compile to heap-allocated state machines<br>Boxed to objectnoboxing is heap allocation by definition
Warn<br>C# 13 introduced allows ref struct as a generic type parameter constraint, which lets generic code accept ref structs including Span. It does not lift the field, closure, or async restrictions — it only widens what generic APIs can accept as a type argument.
Slicing and indexing
Slicing is the operation span-first code leans on constantly. It produces a new Span over a subrange of the same backing memory — pointer arithmetic and a length, nothing copied.
ReadOnlySpanchar> text = "2026-07-23T14:05:00Z";
ReadOnlySpanchar> datePart = text[..10]; // "2026-07-23"<br>ReadOnlySpanchar> timePart = text[11..19]; // "14:05:00"<br>ReadOnlySpanchar> 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.
Stack allocation with stackalloc
stackalloc paired with Span 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.
Spanbyte> buffer = stackalloc byte[256];<br>int written = EncodeHeader(request, buffer);<br>socket.Send(buffer[..written]);
Danger<br>A 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.Shared instead and rent a heap buffer with a bound.
Span-first parsing
Parsing is where span-first...