Span-First C#: Designing Around Span<T>

11 min read Original article ↗

What Span<T> actually is

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:

  • Managed arraysint[], string (via AsSpan()), any T[]
  • Stack memorystackalloc blocks
  • Unmanaged memory — pointers from native interop, wrapped via MemoryMarshal

Note 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<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.

The Span/Memory type hierarchy

Four related types cover the mutable/read-only and stack/heap axes:

TypeMutabilityStorageHeap-storableTypical use
Span<T>read/writestack, array, unmanagednosynchronous hot-path parameter
ReadOnlySpan<T>read-onlystack, array, unmanaged, string datanoparsing, string slicing
Memory<T>read/writearray, unmanaged onlyyesasync methods, fields, closures
ReadOnlyMemory<T>read-onlyarray, unmanaged onlyyesasync 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.

Ref struct constraints

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.

OperationAllowedWhy
Local variableyeslifetime is the stack frame
Method parameter / returnyestracked via ref lifetime rules
Instance field of a classnoclass instances are heap objects with unbounded lifetime
Instance field of a non-ref structnothat struct could itself be boxed or heap-stored
Generic type argumentnogenerics may be reified for reference types (pre-C# 13 allows ref struct)
Captured in a lambda / local function closurenoclosures are compiled to heap-allocated display classes
Used across an awaitnothe compiler-generated state machine is a heap object
Used in an iterator (yield return)noiterators also compile to heap-allocated state machines
Boxed to objectnoboxing is heap allocation by definition

Warn C# 13 introduced 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 and indexing

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.

Stack allocation with stackalloc

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]);

Danger 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<T>.Shared instead and rent a heap buffer with a bound.

Span-first parsing

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-first (allocates a new string per field)

string line = "2026-07-23,LEWISVILLE,72.4,SUNNY";
string[] parts = line.Split(',');
string date = parts[0];
double temp = double.Parse(parts[2]);

Span-first (zero intermediate string allocations)

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.

Tip 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 API design

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.

Note The same logic applies to string-accepting APIs. A method that takes 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.

MemoryMarshal and interop

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.

Warn 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 async boundary problem

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.

Allocation comparison

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.

ApproachMean timeAllocated / opGen0 collections
string.Split(',') + Parse~410 ns~192 Bhigh
ReadOnlySpan<char> + manual scan~95 ns0 Bnone

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.

Common pitfalls

Slicing past the point of validity

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.

Assuming Slice is free of bounds checks entirely

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.

Treating ReadOnlySpan<char> as interchangeable with string in dictionaries

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.

Forgetting ref struct rules block LINQ

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.

When span-first doesn't pay off

  • Object graphs, not buffers. Span-first is a memory-layout technique for contiguous primitive or blittable data. It has nothing to offer code that's fundamentally about references between heap objects.
  • Low call volume. If a code path runs a handful of times per request rather than in a tight per-element loop, the allocation avoided is noise next to the rest of the request's cost, and the added indexing-based code is harder to read than the string/array equivalent.
  • Public library surface aimed at broad consumption. Ref struct parameters can't be used from `dynamic`, can't cross certain reflection-based or expression-tree-built call sites, and complicate the API for callers who don't have a performance reason to care. Weigh that against the audience before making it the primary shape.
  • Anything that must cross an await inline. If the natural shape of the method is "await, then touch the buffer, then await again," the Memory<T>/Span<T> layering split adds a second method and a seam that may not be worth it for a cold path.

Tip Reach for span-first where allocation count or GC pressure is measured and matters — parsers, serializers, protocol codecs, hot request paths. Everywhere else, a normal string/array-based method that's easier to read is the right default; profile before converting it.