← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Simple Explanation

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.

The Technical Definition

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.

Ordinary struct

ref struct

Why Does It Exist?

The Problem

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.

The Solution

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.

Big Picture

The one rule underneath all four restrictions

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.

How It Works — the four restrictions, one at a time

RESTRICTION 1 — NO BOXING
WHY IT'S BANNED
Span<byte> buffer = stackalloc byte[16];
object boxed = buffer; //  compile-time error — cannot convert Span<byte> to object
RESTRICTION 2 — NOT A FIELD OF AN ORDINARY CLASS
WHY IT'S BANNED
public class Parser
{
    private ReadOnlySpan<char> _text; //  compile-time error — field cannot be ref struct type
}
RESTRICTION 3 — NO LAMBDA CAPTURE, NO ITERATOR STATE
WHY IT'S BANNED
Span<int> numbers = stackalloc int[4] { 1, 2, 3, 4 };
Func<int> sum = () => numbers[0] + numbers[1]; //  cannot capture ref struct in a lambda
RESTRICTION 4 — NEVER LIVE ACROSS AN await
WHY IT'S BANNED
async Task ProcessAsync()
{
    Span<byte> buffer = stackalloc byte[64];
    await Task.Delay(1); //  if 'buffer' were used after this line, compile-time error
}

Simple Example — writing your own ref struct

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.

Real-World Example — a specialized tokenizer

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.

Analogy

A visitor badge, not a permanent ID card

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.

Under the Hood

WHY Memory<T> HAD TO EXIST — A DIRECT CONSEQUENCE OF THESE RULES
1. THE PROBLEM Span<T>'S RESTRICTIONS CREATE
2. THE SOLUTION — Memory<T> IS DELIBERATELY NOT A ref struct
3. THE PATTERN THIS ESTABLISHES
4. C# 13's PARTIAL RELAXATION — allows ref struct

Common Confusion

1. "ref struct is about performance" — no, it's about safety

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.

2. "A struct and a ref struct are basically the same, just with extra rules"

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.

3. "I should make my own types ref struct just to be safe"

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.

Common Mistakes

Mistake 1 — Trying to store a Span<T> in a class field "just for this one case"

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.

Mistake 2 — Passing a Span<T> into an async method that awaits before using it

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.

Mistake 3 — Assuming C# 13's allows ref struct removes the restrictions generally

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.

When Should I Use It?

Write your own ref struct when

Skip ref struct when

Rule of thumb: If your type's core purpose is to be a fast, non-owning view over memory that some other party owns — the way 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.

Mental Model

ref struct = "this value is only ever valid for the current stack frame — the compiler will not let it escape."

One rule, four faces: no boxing, no class fields, no lambda/iterator capture, no crossing await — every one of these is a heap-lifetime escape hatch, and every one is closed.

Memory<T> exists specifically because 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.

Key Takeaway


Check Your Understanding

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?

Show answer

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?

Show answer

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?

Show answer

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?

Show answer

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?

Show answer

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.