Skip to content

Span, Memory & ref structs

Key Points

  • Span<T> is a ref struct — a stack-only, allocation-free view over contiguous memory. Backs by an array, a stack-allocated buffer, or unmanaged memory.
  • Memory<T> is a regular struct that wraps the same kind of view but can live on the heap and cross await.
  • ref struct types must live on the stack: cannot be fields of class, cannot be boxed, cannot be captured by lambdas, cannot survive await.
  • C# 13 added the allows ref struct constraint, letting generic methods accept ref struct type parameters.
  • C# 14 gave first-class language support for Span<T> — direct conversion from arrays, more inference, fewer special cases.
  • ReadOnlySpan<char> is the right shape for substring/parsing — every string exposes one via AsSpan().
  • stackalloc T[n] + assign to Span<T> is the modern alloc-free buffer pattern.

Concepts (deep dive)

What is a ref struct?

A ref struct is a struct with extra restrictions enforced by the compiler:

  • Must live on the stack — cannot be a field of any class or non-ref struct.
  • Cannot be boxed — cannot be assigned to object, used as an interface (without generic constraint), or stored in a non-ref struct collection.
  • Cannot be captured by lambdas or local functions.
  • Cannot be used as a generic type argument unless the parameter has allows ref struct (C# 13+).
  • Cannot cross await or yield — the async state machine would need to lift it to the heap.

The runtime rule is straightforward: a ref struct holds a managed pointer (in the case of Span<T>, into an array or a stack buffer). Letting that pointer escape the stack frame breaks GC safety. The restrictions enforce stack-only lifetime.

Span<T> — the canonical ref struct

int[] arr = { 1, 2, 3, 4, 5 };
Span<int> span = arr;
Span<int> middle = span.Slice(1, 3);   // {2, 3, 4}
middle[0] = 99;
// arr is now { 1, 99, 3, 4, 5 } — Span is a view, not a copy

Span<byte> stack = stackalloc byte[256];   // backed by stack memory
ReadOnlySpan<char> hello = "hello".AsSpan();   // backed by string

Span<T> is "any contiguous block of Ts". You can slice it, index it, iterate it, pass it. It's bounds-checked. It's zero-allocation when working with existing memory.

Memory<T> for async pipelines

public async Task<int> ReadAsync(Memory<byte> buffer, CancellationToken ct)
{
    int total = 0;
    while (total < buffer.Length)
    {
        int n = await _stream.ReadAsync(buffer.Slice(total), ct);
        if (n == 0) break;
        total += n;
    }
    return total;
}

Memory<T> is a struct (not ref struct) — it can survive across awaits and be a field. Convert to Span<T> at the synchronous core: memory.Span.

   ┌──────────────────────────────────────────────────────────┐
      Async-friendly view: Memory<T> (heap-friendly)          
                                                             
         memory.Span                                          
                                                             
      Stack-only view: Span<T>                                
   └──────────────────────────────────────────────────────────┘

Pattern: parameters and return types use Memory<T> (or ReadOnlyMemory<T>); inner sync code converts to Span<T> for fast operations.

Stack-allocate small, heap-allocate large

const int Threshold = 256;
Span<byte> buffer = size <= Threshold
    ? stackalloc byte[size]
    : new byte[size];

Process(buffer);

Why this pattern: zero allocation for the common (small) case; correctness for arbitrary sizes. The Span<T> interface is identical regardless of where the bytes live.

⚠️ Don't stackalloc in a loop. The stack frame holds them all until method return.

ref struct restrictions in practice

public class Wrapper
{
    private Span<int> _span;   // ❌ compile error: Span<int> cannot be field of class
}

public ref struct StackOnly { /* ok */ }
public class Heap
{
    private StackOnly _x;       // ❌ compile error: ref struct can't be field of class
}

public Span<int> M(int[] data) => data;   // ✅ Span<T> as return is fine if you don't escape stack

If your design needs Span<T> as a field, that's a signal to redesign — usually you want Memory<T> (or T[]).

allows ref struct (C# 13)

Before C# 13, you couldn't write a generic that accepted a ref struct:

// ❌ Pre-C# 13: T cannot be Span<int>
public static void Print<T>(T value) where T : IFormattable
    => Console.WriteLine(value);

C# 13 introduced the constraint:

public static void Print<T>(T value) where T : allows ref struct
{
    Console.WriteLine(value);
}

Print(stackalloc byte[4]);   // ✅ now compiles

This unlocks generic methods over Span<T> etc. — finally making concepts like "process anything that's enumerable" work for ref structs.

C# 14 — first-class Span support

C# 14 (mid-2026) makes Span<T> and ReadOnlySpan<T> a more first-class part of the language:

  • Direct array → Span<T> conversion in more contexts (no explicit .AsSpan()).
  • Pattern matching with span patterns.
  • Implicit conversions from T[] and string to ReadOnlySpan<T> / ReadOnlySpan<char> in more places.

The result: less boilerplate, fewer special cases. Old code using string.AsSpan() everywhere becomes unnecessary.

Utf8JsonReader, RegexEnumerator, etc.

Many BCL high-perf APIs are ref structs:

ReadOnlySpan<byte> json = "{\"x\":1}"u8;   // u8 string literal — UTF-8 byte span
var reader = new Utf8JsonReader(json);     // ref struct
while (reader.Read()) { /* tokenize without allocations */ }

The pattern: stack-only object that drives over a ReadOnlySpan<T> for zero-alloc parsing.

Common idioms

Parse without allocation:

public static int ParseInt(ReadOnlySpan<char> s)
    => int.Parse(s, NumberStyles.Integer, CultureInfo.InvariantCulture);

ReadOnlySpan<char> input = "12345".AsSpan();
int n = ParseInt(input);

Slice without allocation:

ReadOnlySpan<char> name = fullName.AsSpan(0, fullName.IndexOf(','));

Format into a buffer:

Span<char> buffer = stackalloc char[32];
if (value.TryFormat(buffer, out int written, "G", CultureInfo.InvariantCulture))
{
    var formatted = buffer.Slice(0, written);
    // use without allocation
}

Span<T> and bounds checking

Span<T> indexing is bounds-checked, but the JIT eliminates the check in many loops:

public int Sum(ReadOnlySpan<int> data)
{
    int sum = 0;
    for (int i = 0; i < data.Length; i++)   // JIT eliminates bounds check
        sum += data[i];
    return sum;
}

Combined with .NET 9's loop optimizations, idiomatic Span<T> loops are often faster than int[] loops because the JIT special-cases them.

Memory<T> in async I/O

Modern stream APIs prefer Memory<T>:

public abstract class Stream
{
    public abstract ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct);
}

You pass a buffer in (rented from ArrayPool or stack-allocated → wrapped) and the stream fills it. No allocation per call.

byte[] rented = ArrayPool<byte>.Shared.Rent(4096);
try
{
    int n = await stream.ReadAsync(rented.AsMemory(0, 4096), ct);
    Process(rented.AsSpan(0, n));   // sync core uses Span
}
finally { ArrayPool<byte>.Shared.Return(rented); }

How it works under the hood

Span<T> is roughly:

public readonly ref struct Span<T>
{
    private readonly ref T _reference;   // managed pointer
    private readonly int _length;
}

ref T _reference is a byref — a managed pointer the GC can update if the underlying object moves. The compiler ensures it doesn't escape the stack (otherwise the GC could move what it points to and invalidate the pointer).

Memory<T> is:

public readonly struct Memory<T>
{
    private readonly object? _object;   // T[] or string or MemoryManager<T>
    private readonly int _index;
    private readonly int _length;
}

Memory<T>.Span constructs a Span<T> from the underlying object — fast, but requires unwrapping by type.

stackalloc is a CLR primitive: at JIT time, the size is known (or computed) and stack space is reserved. The resulting pointer lives in a Span<T> for safety.

ReadOnlySpan<char> over a string is special: strings are pinned by their nature (the GC respects string interior pointers). The Span just holds a managed pointer to the string's character data.

C# 13's allows ref struct constraint is a JIT-level concept: the compiler inserts RefStructConstraint metadata; the runtime allows the type to be a ref struct. Specialization happens per-instantiation — generics over ref structs don't "share" code the way other generics do.


Code: correct vs wrong

❌ Wrong: storing Span<T> in a field

public class Wrapper
{
    private Span<int> _span;   // ❌ compile error
}

✅ Correct: store Memory<T> (or array) in fields

public class Wrapper
{
    private readonly Memory<int> _memory;   // ok
    public ReadOnlySpan<int> Span => _memory.Span;
}

❌ Wrong: Span<T> across await

public async Task ProcessAsync(byte[] data)
{
    Span<byte> span = data;
    await Task.Yield();
    Process(span);            // ❌ compile error: span can't survive await
}

✅ Correct: convert at the boundary

public async Task ProcessAsync(Memory<byte> data, CancellationToken ct)
{
    await PreambleAsync(ct);
    Process(data.Span);        // span lives only in the sync core
}

private void Process(Span<byte> span) { /* ... */ }

❌ Wrong: stackalloc in a loop

for (int i = 0; i < 100_000; i++)
{
    Span<byte> buf = stackalloc byte[256];   // accumulates on stack
    Process(buf);
}

✅ Correct: hoist outside the loop

Span<byte> buf = stackalloc byte[256];
for (int i = 0; i < 100_000; i++)
{
    buf.Clear();
    Process(buf);
}

❌ Wrong: returning a Span<T> over local stack

public Span<byte> Make()
{
    Span<byte> local = stackalloc byte[64];
    return local;   // ❌ compile error — local stack memory escapes
}

The compiler catches this; you can't escape a stack-allocated Span<T> from its method.


Design patterns for this topic

Pattern 1 — "Span on the synchronous path; Memory across awaits"

  • Intent: zero allocation in the inner core; heap-friendly type at the async boundary.
  • Code sketch: see "Memory in async I/O" above.

Pattern 2 — "Stack-allocate small; heap-allocate large"

  • Intent: zero allocation for common small sizes.
  • Code sketch:
const int Threshold = 1024;
Span<byte> buf = size <= Threshold ? stackalloc byte[size] : new byte[size];

Pattern 3 — "ReadOnlySpan for parsing"

  • Intent: avoid string.Substring allocations.
  • Code sketch:
public static (ReadOnlySpan<char> First, ReadOnlySpan<char> Rest) Split(ReadOnlySpan<char> s, char sep)
{
    int idx = s.IndexOf(sep);
    if (idx < 0) return (s, ReadOnlySpan<char>.Empty);
    return (s.Slice(0, idx), s.Slice(idx + 1));
}

Pattern 4 — "ArrayPool + Memory.AsMemory"

  • Intent: reuse buffers across requests.
  • Code sketch:
byte[] rented = ArrayPool<byte>.Shared.Rent(size);
try
{
    Memory<byte> m = rented.AsMemory(0, size);
    await stream.ReadAsync(m, ct);
    Process(m.Span);
}
finally { ArrayPool<byte>.Shared.Return(rented); }

Pattern 5 — "u8 string literals for UTF-8"

  • Intent: compile-time UTF-8 literal as ReadOnlySpan<byte>.
  • Code sketch:
ReadOnlySpan<byte> hello = "hello"u8;   // C# 11+

Pros & cons / trade-offs

Type Pros Cons
Span<T> Zero alloc; bounds-checked Stack-only; can't cross await
Memory<T> Async-friendly Slight overhead vs raw T[]
ref struct Stack-only lifetime Heavy restrictions
stackalloc + Span Best perf for small fixed sizes Stack overflow if abused
C# 14 first-class Span Less boilerplate Newer; not in older toolchains

When to use / when to avoid

  • Use ReadOnlySpan<char> for any string-parsing or substring-manipulation code path.
  • Use Memory<T> / ReadOnlyMemory<T> for async I/O and stream APIs.
  • Use Span<T> in synchronous hot paths.
  • Use stackalloc for small, bounded buffers that fit in low-KB.
  • Avoid storing Span<T> in a field — use Memory<T>.
  • Avoid stackalloc in loops or with large/unbounded sizes.

Interview Q&A

Q1. What's a ref struct? A struct with extra restrictions: must live on the stack, can't be a field of class, can't be boxed, can't cross await or yield. Span<T> is the canonical example.

Q2. Difference between Span<T> and Memory<T>? Span<T> is a ref struct — stack-only, bounds-checked, zero-allocation view. Memory<T> is a regular struct — heap-friendly, can survive await, can be a field. Convert via memory.Span.

Q3. Why can't Span<T> cross await? Because the async state machine boxes its locals to the heap on suspension. A Span<T> holds a managed pointer that the GC tracks specifically — boxing it would break the contract. The compiler refuses.

Q4. What's stackalloc and when do you use it? Allocates space on the call's stack frame. Assign to Span<T> for bounds-safe access. Use for small, bounded buffers (parsing, formatting). Don't use in loops.

Q5. What's allows ref struct? A C# 13 generic constraint that lets type parameters accept ref struct types. Unlocks generic algorithms over Span<T> and friends.

Q6. Why might a Span<T> loop be faster than an array loop? The JIT special-cases Span<T> indexing for bounds-check elimination. Combined with .NET 9's loop optimizations, idiomatic Span<T> over a known-length range often compiles to bounds-check-free code — sometimes faster than the equivalent array loop.

Q7. What's a u8 string literal? C# 11+ syntax: "hello"u8 produces a ReadOnlySpan<byte> of UTF-8 bytes at compile time. Useful for serialization, protocol parsing, and APIs that take UTF-8 byte spans directly.

Q8. How do Span<T> and ArrayPool<T> work together? ArrayPool<T>.Shared.Rent(n) returns a pooled T[]. Wrap as Span<T> (sync) or Memory<T> (async) for safe consumption. Always pass actual length via Slice because Rent may return a larger bucket size.

Q9. What does [InlineArray] do? A C# 12 attribute on a struct that turns it into a fixed-size inline array — accessible via Span<T>. Useful for very-small fixed-capacity buffers.

Q10. What's the difference between Span<char> and string? string is immutable, heap-allocated. Span<char> is a view that may overlay any contiguous char memory, including a string's interior. Mutating a Span<char> over a string is undefined behavior — use MemoryMarshal.GetReference cautiously, never as user-facing code.

Q11. How does the [OverloadResolutionPriority] attribute help with Span overloads? C# 13+ attribute that breaks ties when multiple overloads could match. Lets BCL APIs offer both T[] and ReadOnlySpan<T> overloads with predictable resolution.

Q12. Why should a method that doesn't mutate its buffer take ReadOnlySpan<T> instead of Span<T>? Documentation: "I won't write." Lets callers pass anything that converts to ReadOnlySpan<T> (string, immutable arrays). Communicates intent to readers.


Gotchas / common mistakes

  • ⚠️ Storing Span<T> in a field — compile error. Use Memory<T>.
  • ⚠️ Span<T> across await — compile error. Convert to Memory<T> at the boundary.
  • ⚠️ stackalloc in a loop — accumulates on stack.
  • ⚠️ Mutating Span<char> over a string — undefined behavior; strings are supposed to be immutable.
  • ⚠️ Forgetting to slice an ArrayPool-rented array — using arr.Length instead of the requested length.
  • ⚠️ Returning a Span<T> over a local — the compiler catches it, but the temptation exists.
  • ⚠️ Passing Memory<T> to a Span<T> parameter inside a closure — closure captures Memory, accesses .Span later — pointer may now be invalidated if the underlying buffer moved (rare but possible with unmanaged sources).

Further reading