Span, Memory & ref structs
Key Points
Span<T>is aref 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 crossawait.ref structtypes must live on the stack: cannot be fields ofclass, cannot be boxed, cannot be captured by lambdas, cannot surviveawait.- C# 13 added the
allows ref structconstraint, letting generic methods acceptref structtype 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 — everystringexposes one viaAsSpan().stackalloc T[n]+ assign toSpan<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 structcollection. - 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
awaitoryield— 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
stackallocin 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[]andstringtoReadOnlySpan<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:
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
✅ 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.Substringallocations. - 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:
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
stackallocfor small, bounded buffers that fit in low-KB. - Avoid storing
Span<T>in a field — useMemory<T>. - Avoid
stackallocin 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. UseMemory<T>. - ⚠️
Span<T>acrossawait— compile error. Convert toMemory<T>at the boundary. - ⚠️
stackallocin a loop — accumulates on stack. - ⚠️ Mutating
Span<char>over astring— undefined behavior; strings are supposed to be immutable. - ⚠️ Forgetting to slice an ArrayPool-rented array — using
arr.Lengthinstead of the requested length. - ⚠️ Returning a
Span<T>over a local — the compiler catches it, but the temptation exists. - ⚠️ Passing
Memory<T>to aSpan<T>parameter inside a closure — closure capturesMemory, accesses.Spanlater — pointer may now be invalidated if the underlying buffer moved (rare but possible with unmanaged sources).