Span<T> isn't safe by convention — it's safe because the compiler refuses to compile the code that would make it unsafe.
Earlier in this Part, you met Span<T> and ReadOnlySpan<T> — types that give you a view over a contiguous block of memory, sometimes memory that lives on the stack, without copying it. That should worry you a little. A type that can point at stack memory is a type that can, in principle, outlive the stack frame it points into — and reading memory after its owning frame is gone is exactly the kind of bug that used to require unsafe code and a pointer to produce.
Span<T> and ReadOnlySpan<T> avoid that fate not through convention or discipline, but because they are declared as a special kind of struct — a ref struct — and the C# compiler enforces a specific set of restrictions on every ref struct that make it structurally impossible to write the code that would let one outlive its valid memory.
In this lesson, you'll learn exactly what a ref struct is, the four concrete restrictions the compiler enforces on it and why each one exists, how those restrictions directly explain why Memory<T> had to exist as a separate type, and how to write your own small, specialized ref struct.
A ref struct is an ordinary struct with one extra word in its declaration — public ref struct MyType { ... } — that tells the compiler: "this type's instances may hold a reference to memory whose lifetime is tied to a single stack frame, so never let an instance of this type escape beyond that frame." The compiler then enforces that promise everywhere the type is used, not just where it's declared.
ref struct is a C# 7.2 language feature. A type declared with the ref struct modifier is subject to a set of compile-time-enforced restrictions — collectively sometimes called the "stack-only" rules — that guarantee no instance of the type can ever be moved to a location the runtime can't statically prove is still valid by the time it's accessed. Span<T> and ReadOnlySpan<T> are both declared as ref struct types in the BCL for exactly this reason — internally, a Span<T> is essentially a reference plus a length, and that reference may point into a stackalloc buffer (the next lesson's topic) that only exists for the current method call.
object or an interfaceawaitawaitallows ref struct (C# 13+)Span<T> can wrap a stackalloc'd buffer that lives entirely on the current method's stack frame. That's enormously useful — it lets you work with stack memory through a safe, bounds-checked, array-like API instead of raw pointers. But it's also dangerous in a very specific way: the moment that method returns, the stack frame is gone, reused by the next call. If a Span<T> pointing into it could somehow be smuggled out — returned, stored on the heap, captured by a closure, stashed in a field — you'd end up with a value that looks completely normal but silently reads garbage, or worse, reads whatever unrelated data the next method call happens to write into that same stack region. This is precisely the class of bug (a "dangling pointer") that made raw pointer-based stack access an unsafe-only feature for the first fifteen years of C#'s existence.
Rather than trusting developers to manually track which spans are "still safe" (the way you'd have to with raw pointers), the C# team gave the compiler a mechanical, foolproof way to guarantee safety: ban, at compile time, every single language construct that could cause a value's lifetime to outlive its declaring stack frame. A lambda closure, an iterator's hidden state class, an async method's state machine, and a boxed object are all heap-allocated constructs whose lifetime is governed by the garbage collector, not by the stack's strict last-in-first-out discipline — exactly the mechanism the Advanced Part I stack-vs-heap lesson walked through for ordinary value types. A ref struct is simply forbidden from entering any of those heap-lifetime contexts. If the compiler won't let the code compile, there's no dangling-pointer bug to have — full stop, with zero runtime cost for the guarantee.
Every restriction you're about to see is the same rule, applied in a different place: a ref struct value may never be placed anywhere whose lifetime the compiler cannot statically prove ends no later than the current stack frame's. Boxing, class fields, closures, iterator state, and async state machines are all, mechanically, heap allocations with GC-governed lifetimes — lifetimes the compiler cannot bound to "this call and no longer." So all five are off-limits, for the exact same underlying reason.
object reference. That reference has no compile-time-bounded lifetime — it can be stored anywhere, for as long as anything keeps it reachable.Span<byte> buffer = stackalloc byte[16];
object boxed = buffer; // compile-time error — cannot convert Span<byte> to object
ref struct value is gone.public class Parser
{
private ReadOnlySpan<char> _text; // compile-time error — field cannot be ref struct type
}
yield return iterators) — both heap-allocated, exactly like restriction 2, just generated by the compiler instead of written by you.Span<int> numbers = stackalloc int[4] { 1, 2, 3, 4 };
Func<int> sum = () => numbers[0] + numbers[1]; // cannot capture ref struct in a lambda
await becomes a field of the heap-allocated async state machine, so execution can suspend and correctly resume later, possibly on a different thread. That's heap lifetime again — forbidden for the same reason as restrictions 1–3.async Task ProcessAsync()
{
Span<byte> buffer = stackalloc byte[64];
await Task.Delay(1); // if 'buffer' were used after this line, compile-time error
}
Nothing stops you from declaring your own ref struct — you're not limited to the BCL's Span<T> family. Here's a small one that pairs a span with a running position, useful as a lightweight cursor over a buffer:
public ref struct SpanCursor
{
private readonly ReadOnlySpan<char> _text;
private int _position;
public SpanCursor(ReadOnlySpan<char> text)
{
_text = text;
_position = 0;
}
public bool TryReadChar(out char value)
{
if (_position >= _text.Length)
{
value = default;
return false;
}
value = _text[_position];
_position++;
return true;
}
public ReadOnlySpan<char> Remaining => _text[_position..];
}
Why this compiles cleanly: SpanCursor holds a ReadOnlySpan<char> field — which is only legal because SpanCursor is itself a ref struct. An ordinary struct or class could not have a ReadOnlySpan<char> field at all (restriction 2, applied recursively). Because SpanCursor is used entirely as a local variable within one method call — never boxed, stored in a field, captured, or held across an await — every restriction is satisfied automatically, with no extra effort from you.
A common real use case for a hand-written ref struct is a small, allocation-free tokenizer for a hot parsing path — splitting a line of CSV or a log line into fields without allocating a single intermediate string:
public ref struct CsvFieldEnumerator
{
private ReadOnlySpan<char> _remaining;
public CsvFieldEnumerator(ReadOnlySpan<char> line) => _remaining = line;
public ReadOnlySpan<char> Current { get; private set; }
public bool MoveNext()
{
if (_remaining.IsEmpty)
return false;
var commaIndex = _remaining.IndexOf(',');
if (commaIndex < 0)
{
Current = _remaining;
_remaining = ReadOnlySpan<char>.Empty;
}
else
{
Current = _remaining[..commaIndex];
_remaining = _remaining[(commaIndex + 1)..];
}
return true;
}
}
// Usage — no intermediate string[] array, no per-field string allocation
var fields = new CsvFieldEnumerator("2026-08-31,Widget,42,19.99");
while (fields.MoveNext())
{
ReadOnlySpan<char> field = fields.Current; // just a view, zero-copy
Console.WriteLine(field.ToString()); // only allocate a string when you actually need one
}
This is exactly the pattern Span<T> itself exists to enable, and it's why the restrictions matter in practice: because CsvFieldEnumerator is a ref struct, the compiler guarantees you can never accidentally store it somewhere unsafe, cache it in a field for "later," or hand it to an async continuation — misuses that would otherwise be easy, subtle mistakes to make with a type this close to raw memory.
A ref struct is like a day-visitor badge issued at the front desk of a secure building. It's valid only inside this building, today. Building security enforces that at every exit: you can't mail the badge to yourself at home (boxing to the heap), you can't laminate it into your permanent employee ID (a field of a class), you can't hand it to a courier who'll deliver it to someone else tomorrow (a lambda closure or iterator state), and you can't pin it to your jacket and go take a lunch break somewhere else and come back expecting it to still be valid (crossing an await, where the "building" — your call stack — may have changed underneath you). None of these are inconveniences for spite; they're exactly what keeps the badge meaningful. The badge works precisely because its validity is tied, provably, to "here, right now" — the moment you try to make it valid anywhere else, it stops making sense, and the front desk (the compiler) simply won't let you try.
Span<T> is a ref struct, it can never be a field of a class, never survive an await, never be captured by a closure or stored for later.Task the caller awaits. Stream.ReadAsync is the canonical example.Span<T>-based API simply cannot express that — the compiler would reject it outright.Memory<T> (covered elsewhere in this Part) is an ordinary struct — no ref struct modifier — specifically so it can be a field, survive an await, and be stored for later use.MemoryManager<T>) plus an offset and length, never a raw stack pointer..Span to get a short-lived Span<T> back — used immediately, within the current call, then discarded.Span<T> — the fast, flexible, stack-capable view, for synchronous, single-call use.Memory<T> — the async-friendly, storable handle, for anything that needs to cross an await or live in a field, converted back to a Span<T> at the point of actual use.ref struct can and cannot do.ref struct could not be used as a type argument for a generic type parameter at all — generic code is compiled once and shared across reference-type arguments, an approach fundamentally incompatible with a type that can't be boxed or stored on the heap the way a reference type argument normally would be.allows ref struct anti-constraint, which tells the compiler "this specific generic parameter is permitted to accept ref struct arguments," and the compiler then re-applies the same stack-only restrictions to every use of that parameter inside the generic type or method.ref struct, and still apply to any generic parameter that isn't explicitly marked this way. If you're working with this feature directly, verify the exact current syntax and applicable BCL types against official, current Microsoft documentation for your SDK version rather than relying on any single example — it's a newer, narrower corner of the language than the core restrictions this lesson is built around.Span<T> happens to also be fast (no copying, no allocation), but the ref struct modifier itself doesn't make anything faster on its own — it makes certain unsafe patterns impossible to compile. The performance benefits come from what Span<T> lets you avoid (allocations, copies); the ref struct restrictions are the safety net that makes exposing that power to ordinary, safe C# code responsible in the first place.
Structurally, yes — a ref struct is still a value type with copy-by-value semantics. But treat the restrictions as load-bearing, not cosmetic: they change where a type can legally appear in your codebase in ways that ripple outward. A method that returns a ref struct, a method that takes one as a parameter, and any type that tries to hold one as a field all inherit consequences from this one modifier.
Only reach for ref struct when your type genuinely needs to hold a Span<T>/ReadOnlySpan<T>-shaped reference to stack or arbitrary memory, or you specifically want to forbid it from ever being boxed, stored in a field, or crossing an await. For an ordinary data-holding struct with no such reference, the restrictions only get in your way — you'd lose the ability to store it in a List<T>, pass it to most async APIs, or use it in LINQ, for no safety benefit at all.
Adding a private Span<byte> _buffer; field to a service class, expecting the compiler to let it through because "it'll only be set and read within the same request."
The compiler rejects this outright, every time, with no exceptions — there's no annotation that makes a class field of ref struct type legal. Use Memory<byte> as the field instead, and call .Span only inside the method that actually needs synchronous, immediate access.
Writing an async Task method that accepts a Span<T> parameter and tries to use it after an await inside that method — this won't compile, because the parameter would need to be preserved across the suspension.
Restructure so any Span<T> use happens entirely before the first await, or redesign the API around Memory<T> if the buffer genuinely needs to survive an asynchronous operation.
Reading about the C# 13 anti-constraint and concluding that ref struct limitations are now largely a thing of the past, or trying to box/field-store a ref struct expecting it to now just work.
The C# 13 feature is a narrow, explicit opt-in for specific generic type parameters — it doesn't change what happens with an ordinary, non-generic ref struct variable, and doesn't lift the no-boxing or no-field rules in general. Treat the classic restriction list in this lesson as the default you should expect almost everywhere.
Span<T>/ReadOnlySpan<T>-shaped field.await.struct is simpler and more flexible.struct or readonly struct.Span<T> is a view over an array, a stackalloc buffer, or a string — it's a strong candidate for ref struct. If it's meant to be stored, passed around freely, and outlive a single call, it isn't.
await — every one of these is a heap-lifetime escape hatch, and every one is closed.Span<T> can't — it's an ordinary struct that trades direct stack access for the freedom to be stored, and gets converted back to a Span<T> only at the point of actual, immediate use.
ref struct is a compiler-enforced "stack-only" contract — instances can never be boxed, stored as a field of an ordinary class, captured by a lambda or iterator, or held across an await.Span<T> and ReadOnlySpan<T> are declared ref struct for exactly this reason — and Memory<T> exists precisely because those restrictions make Span<T> unusable across an await or as a stored field.allows ref struct anti-constraint is a real but narrow, opt-in relaxation for specific generic type parameters — it doesn't overturn the classic restriction list for ordinary use.ref struct types for specialized, allocation-free, synchronous hot-path helpers like parsers and cursors.You've learned what makes a ref struct different from an ordinary struct, and why those differences exist. Let's confirm it sticks.
1. Why can't an instance of a ref struct type be stored as a field of an ordinary (non-ref-struct) class?
Correct: B
Why B is correct: A class instance lives on the heap for as long as the GC considers it reachable — potentially far longer than the stack frame that created a ref struct value. Allowing it as a field would let a dangling reference to invalid stack memory survive past the point it's safe to read.
Why A is incorrect: Size has nothing to do with it — the restriction applies regardless of how small the ref struct is.
Why C is incorrect: Classes routinely contain ordinary struct fields (like int or DateTime) with no restriction — only ref struct types specifically are banned as class fields.
Why D is incorrect: This restriction has nothing to do with mutability — it's entirely about lifetime safety.
Reinforcement: Every ref struct restriction traces back to the same cause: preventing the type from ending up somewhere with a heap-governed lifetime.
2. Which of these is NOT one of the compiler-enforced restrictions on a ref struct?
Correct: C
Why C is correct: Passing a ref struct as an ordinary method parameter is completely legal and extremely common — that's exactly how Span<T> is used throughout the BCL. Parameter passing within a synchronous call chain doesn't extend the value's lifetime beyond the stack.
Why A, B, D are incorrect: All three are genuine, real restrictions covered in this lesson — boxing, lambda capture, and crossing an await are all forbidden because each is a route to heap-governed lifetime.
Reinforcement: The restrictions target specific heap-lifetime escape routes — ordinary, same-call-chain parameter passing isn't one of them.
3. Why does Memory<T> exist as a separate type from Span<T>, rather than Span<T> simply being used everywhere?
Correct: B
Why B is correct: This is the direct, stated consequence of the ref struct restrictions — Span<T> cannot survive an await or be stored as a field, so any API that needs a memory view to persist across asynchronous work or be held onto for later needs a non-ref-struct alternative. Memory<T> is that alternative, convertible back to a Span<T> via .Span when actually used.
Why A is incorrect: Span<T> is typically the faster of the two for direct, synchronous access — Memory<T> trades some of that for storability, not the other way around.
Why C is incorrect: Both are generic over the same element types; type support isn't the distinguishing factor.
Why D is incorrect: Neither type is deprecated — they're complementary, used for different situations (synchronous, single-call use vs. storable/async-friendly use).
Reinforcement: Memory<T>'s entire reason for existing is a direct consequence of the ref struct rules covered in this lesson.
4. What does the C# 13 allows ref struct anti-constraint actually enable?
Correct: B
Why B is correct: It's a narrow, opt-in feature — a generic type parameter must be explicitly marked to accept ref struct arguments, and the compiler continues enforcing the classic stack-only restrictions for any value of that parameter's type used inside the generic code.
Why A is incorrect: This overstates the feature considerably — it's a targeted relaxation for specific, opted-in generic parameters, not a general removal of the restrictions.
Why C is incorrect: Boxing remains forbidden for ref struct types regardless of this feature — it addresses generic type arguments specifically, not boxing.
Why D is incorrect: Ordinary collections like List<T> still cannot hold ref struct elements — this feature is about specific, purpose-built generic APIs designed with the anti-constraint in mind, not general-purpose collections.
Reinforcement: Treat this as a careful, narrow evolution of the rules for specific scenarios — not a general lifting of the restrictions this lesson covers.
5. You're designing a small type that wraps a ReadOnlySpan<byte> to walk through a binary protocol message, used entirely within one synchronous method call. Should it be a ref struct?
Correct: B
Why B is correct: This is exactly the profile a ref struct is designed for — holding a span-typed field (impossible in an ordinary struct or class), used entirely within one synchronous call, so the no-boxing/no-field/no-capture/no-await restrictions cost nothing because none of those contexts were needed anyway.
Why A is incorrect: Ref struct types can absolutely have fields, including span-typed ones — that capability is precisely why they exist.
Why C is incorrect: There's no inherent performance penalty from the ref struct modifier itself — it's a compile-time restriction, not a runtime cost.
Why D is incorrect: They are meaningfully different — a ref struct carries the four stack-only restrictions this lesson covers, an ordinary struct does not.
Reinforcement: The moment a type needs a span-shaped field for synchronous, single-call use, ref struct is the right — and often only legal — choice.
You now understand exactly what makes Span<T> and ReadOnlySpan<T> provably safe — next, you'll learn the tool that puts memory on the stack for them to point at in the first place: stackalloc.
dotnetmadeeasy.com — Learn C# and .NET, the right way.