AlgoMaster Logo

ref Struct

Last Updated: May 17, 2026

18 min read

A ref struct is a struct that the compiler refuses to let escape to the heap. It must live on the stack for its entire lifetime, which makes it the right tool for high-performance code that wants the convenience of an object without the cost of garbage collection. The canonical examples are Span<T> and ReadOnlySpan<T>, two types you'll meet the moment you start writing parsers, buffer handlers, or anything that processes bytes without allocating.

What a ref Struct Is

A ref struct is declared with the ref modifier in front of the struct keyword. That single keyword changes everything about how the compiler treats the type. A regular struct can be boxed into an object, stored in a field of a class, captured by a lambda, and passed across await. A ref struct cannot do any of those things, because each of them would let an instance survive past the stack frame that created it.

That's a legal declaration, but it's also the start of a long list of rules the compiler will now enforce. Before getting to the rules, it's worth seeing what the type is for. The whole point of forbidding heap allocation is to make safe wrappers around stack memory.

A regular struct, by contrast, can be put anywhere. You can box it, store it inside a class, pass it across an async method, capture it in a lambda. That flexibility costs you the ability to hand out raw pointers into stack memory, because the compiler can't prove the pointer will still be valid when the struct is finally used.

The ByteWindow holds a Span<byte> that points into a stack buffer. If ByteWindow could ever be stored on the heap, that span would outlive the stack frame the buffer was allocated in, and the next caller would scribble over those bytes. By declaring ByteWindow as ref struct, the compiler guarantees it can't escape, which makes the inner Span<byte> safe to hold.

The C# compiler tracks ref struct lifetimes with rules collectively called ref safety. The rules look intricate from the outside, but they all serve one goal: never let a reference into stack memory be observable from a place that has already left the stack.

The ref struct modifier has been in the language since C# 7.2, which is when Span<T> shipped. Newer C# versions have layered features on top: scoped parameters (C# 11), ref fields inside ref structs (C# 11), and the allows ref struct generic constraint (C# 13). Each of these expanded what ref structs could do without breaking the safety guarantee.

Why Stack-Only Matters

To see why the stack-only rule matters, look at what could go wrong without it. stackalloc reserves memory on the current stack frame and gives you back a pointer to it. When the method returns, that memory is reused for the next call. A pointer into stack memory is valid for exactly the lifetime of the frame that allocated it. Not one instruction longer.

The compiler flags this with CS8350: This combination of arguments to 'Span<byte>.implicit operator Span<byte>(byte*, int)' is disallowed because it may expose variables referenced by parameter 'pointer' outside of their declaration scope. Translated into plain English: returning local from MakeBuffer would let the caller read 16 bytes of memory that the runtime has already taken back. The compiler refuses.

Span<T> is itself a ref struct, which is what enables that check. Because the compiler knows Span<T> can never be stored on the heap, it can track every place a span flows and prove that no caller can hold on to it past the source's lifetime. Drop the ref struct constraint, and the proof breaks.

The chain works in one direction. Stack-only enables safe wrappers around stack pointers. Safe wrappers enable zero-allocation APIs like Span<T>. Zero-allocation APIs enable parsing, buffer handling, and serialization code that runs without producing GC pressure. Knock the first link out and the rest collapses.

Garbage collection is the second piece. A ref struct is never tracked by the GC because it's never on the heap. Methods that allocate ref structs allocate them as part of the stack frame, which is reclaimed for free when the method returns. There's no allocation cost, no collection cost, and no risk of a long GC pause stretching out a tight loop.

Span&lt;T&gt; and ReadOnlySpan&lt;T&gt; in Practice

The two ref structs you'll touch most often are Span<T> and ReadOnlySpan<T>, both defined in the System namespace. A Span<T> is a view over a contiguous block of memory. The block can be a slice of an array, a slice of a stackalloc buffer, or memory from a native pointer. The span doesn't own the memory; it points at it.

firstThree is a Span<int> that points at the first three slots of orderIds. It doesn't copy any data. Iterating it reads directly from the array. Slicing is O(1) because it's just computing a new pointer and length; no memory is moved.

The same shape applies to a stack buffer. stackalloc returns a Span<T> in modern C#, which is the only safe way to expose stack memory.

The buffer never touches the heap. It lives on the stack for the duration of the method, and the Span<byte> is the safe handle to it. When the method returns, the bytes are gone and so is the span.

ReadOnlySpan<T> is the same idea with mutation forbidden. It's the right type for parsing a string without copying it. Every string in .NET can be viewed as a ReadOnlySpan<char> for free, no allocation involved.

AsSpan() doesn't copy the string. The two slices are pointers into the original discountCode characters, with lengths set to 4 and 2. The only allocation in this snippet is ToString() at the end, which is there to print the slices. If you only needed to compare them or read individual characters, you could avoid even that.

Both Span<T> and ReadOnlySpan<T> are declared in the BCL as ref struct. That declaration is what makes them safe to point into stack memory in the first place. Strip the ref struct modifier and the whole API collapses, because any span could be stored in a field somewhere and read after the underlying memory is gone.

The readonly ref struct form is allowed and recommended for ref structs whose fields don't change after construction. Combining readonly with ref struct is a normal thing to see and means "this stack-only value can't mutate its fields."

A small example of the difference between Span<T> (mutable) and ReadOnlySpan<T> (immutable view) helps cement the relationship between the two:

DoubleInPlace takes a Span<int> because it mutates the elements. SumOnly takes a ReadOnlySpan<int> because it only reads. Passing an int[] works for both: there's an implicit conversion from T[] to Span<T> and from T[] (or Span<T>) to ReadOnlySpan<T>. Picking the right span type at each API boundary is how you document, at compile time, whether a method intends to read or write.

Stack vs Heap: Where the Pieces Live

The diagram below shows where the bytes actually sit when you parse a string with a ReadOnlySpan<char> and process a Span<byte> over a stack buffer. The string itself lives on the heap because strings are reference types. The span is two values (a managed pointer and a length) that live on the stack.

Two things to notice. First, the spans themselves are tiny: just a pointer and a length, sitting on the stack as part of the current method's frame. Second, the memory they point at can be either heap or stack. The span doesn't care which, because the compiler's ref safety rules guarantee the span never outlives whatever it's pointing at.

That second point is what the stack-only rule buys you. The compiler is willing to let a Span<byte> point into a stackalloc buffer precisely because the span itself can't escape the stack. If the span could be assigned to a class field, that class instance might live forever, but the buffer wouldn't, and the span would become a dangling pointer. The ref struct constraint closes that hole.

A concrete payoff: parsing a price string without heap allocation. The string itself is already on the heap (strings always are), but turning "$29.99" into a decimal can be done without allocating anything new.

decimal.TryParse accepts a ReadOnlySpan<char> overload that doesn't allocate. The whole pipeline (taking the string view, trimming the leading $, parsing the digits) runs without producing a single new heap object beyond what the string already cost. In a tight loop processing 100,000 prices, that's the difference between meaningful GC churn and none.

The Rules: What ref Struct Cannot Do

The stack-only rule turns into a list of things the compiler will refuse to compile. Each restriction exists because the equivalent operation could let the ref struct land somewhere the compiler can no longer guarantee its lifetime.

Cannot Be Boxed

Boxing wraps a value type in a heap-allocated object. For a regular struct, boxing happens any time you assign it to object, cast it to an interface, or pass it where an object is expected. For a ref struct, the compiler refuses.

The error is CS0029: Cannot implicitly convert type 'PriceBuffer' to 'object'. The compiler doesn't even consider the box. Boxing would put the ref struct on the heap, which contradicts the whole purpose of the type.

The same rule blocks calling .ToString() through object, putting a ref struct into a Dictionary<object, ...> as a value, or storing it in a List<object>. Any of those would require a box, and the compiler will reject all of them.

Cannot Be a Field of a Non-ref Struct

If a regular struct or class had a Span<int> field, that span would live on the heap any time the struct was boxed or the class was allocated. The compiler blocks the declaration outright.

The error is CS8345: Field or auto-implemented property cannot be of type 'Span<int>' unless it is an instance member of a ref struct. Translation: only another ref struct is allowed to hold a span as a field, because that holder also can't escape the stack.

A ref struct holding another ref struct is fine, because the outer ref struct is also stack-only.

C# 13 relaxes part of this rule via the allows ref struct generic constraint (covered later in this lesson), but the basic shape stays the same: ref struct fields go inside ref structs, not inside classes or regular structs.

Cannot Be Used in async Methods or Iterators

An async method gets rewritten by the compiler into a state machine that captures local variables in a class. That class lives on the heap. If a ref struct local crosses an await, it would land in that heap-allocated state machine, which violates the stack-only rule.

The error is CS4012: Parameters or locals of type 'Span<byte>' cannot be declared in async methods or async lambda expressions. More precisely, you cannot have a ref struct local whose lifetime crosses an await. If the span existed only before the await and the compiler could prove that, the rule would allow it, but in practice the moment you reference the local after the await, the rule kicks in.

Iterators (IEnumerable<T> methods that use yield return) hit the same wall for the same reason. The iterator state machine is a class on the heap, and ref struct locals can't go there.

The error is CS4013: Instance of type 'Span<byte>' cannot be used inside a nested function, query expression, iterator block or async method. The iterator transformation moves locals into a generated class, and ref structs can't live there.

Cannot Be Captured by Lambdas

Closures (lambdas and local functions that capture variables) work by lifting the captured variables onto a closure class on the heap. Same heap, same problem.

The error is CS8175: Cannot use ref local 'data' inside an anonymous method, lambda expression, or query expression. The lambda would need to capture data onto the closure class, and a ref struct can't go on the heap.

Local functions are similar, with one important nuance: a static local function doesn't capture anything, so you can pass a ref struct as a parameter to it without trouble. The trick is that you're passing the value rather than capturing it.

Cannot Implement Interfaces (Mostly)

Implementing an interface implies the type can be referred to by an interface variable. An interface variable is a reference, which means the value would be boxed when assigned to it. Boxing a ref struct is forbidden, so implementing interfaces is forbidden too.

Before C# 8, this was a hard rule with no exceptions. C# 8 added a small carve-out: a ref struct can satisfy a Dispose pattern (a public parameterless Dispose method) and be used in using statements without implementing IDisposable. That made Span<T>-style cleanup possible without violating the rules.

The using statement here works because RentedBuffer has a public Dispose method, not because it implements IDisposable. The compiler binds to the method by pattern, not by interface.

C# 13 (.NET 9) widened the carve-out further: a ref struct can now declare that it implements an interface, as long as it's used only through the allows ref struct constraint (see below). This came along to support APIs like Enumerable.Range(...) returning ref struct iterators that satisfy IEnumerable<T>-style patterns inside generic methods.

Cannot Be a Generic Type Argument (Mostly)

Generic type arguments are usually unconstrained, which means the generic code might assign a T value to a field, box it, or hand it to a delegate. None of those work for a ref struct. So before C# 13, you simply couldn't write List<Span<int>> or Func<Span<int>, int>.

The error is CS0306: The type 'Span<int>' may not be used as a type argument. The list's internals would try to put each Span<int> into a field of the list's backing array, and the compiler rejects the substitution before any of that happens.

C# 13 introduced the allows ref struct constraint, which lets you opt a specific generic parameter into accepting ref structs. Inside the generic method, the parameter is treated as a ref struct, which means the same restrictions apply (no boxing, no capture, no heap storage), but the substitution itself is now legal.

The constraint is opt-in by design. Most generic code doesn't need to handle ref structs and shouldn't have to think about the extra rules. Add allows ref struct only on the rare APIs where the substitution genuinely makes sense, such as the BCL's MemoryMarshal helpers.

The six restrictions look like a lot at once, but they all come from one rule: nothing that lives on the heap can ever hold a ref struct, directly or indirectly. Read each restriction in those terms and they stop feeling arbitrary.

The scoped Modifier (C# 11)

C# 11 added the scoped modifier to give callers more control over how far a ref struct (or ref parameter) is allowed to escape. By default, the compiler infers an escape scope for each ref struct parameter, and that inference can be conservative in surprising ways. scoped lets you say "this parameter does not escape the call", which both tightens the safety check and unlocks some patterns the inference would otherwise reject.

The base rule the compiler applies is called safe-to-escape. Every ref struct value has a scope it can flow into without violating lifetime safety. A Span<int> over stackalloc memory has a scope of "the current method"; the span cannot return out of the method, or be assigned to an out parameter that escapes, or be stored in a longer-lived ref struct.

A scoped parameter narrows the scope to the call itself.

The scoped keyword on the parameter says "I will not let this span escape this call." The compiler then knows it doesn't need to consider that the span might flow out through a return value or an out parameter. For a pure consumer like SumFirstThree, scoped is a tightening contract: callers can pass any span at all, including stack-allocated ones, with no risk that the function smuggled the reference out.

Without scoped, the compiler treats parameter spans more conservatively. Methods that return a ref struct or assign one to a ref parameter are sometimes rejected because the inference assumes the worst case.

In the snippet above, the scoped modifier blocks the assignment Source = source because the parameter is scoped to the call and cannot survive into the returned value. That's the intended use of scoped: it stops a span from accidentally being captured into a longer-lived ref struct.

The companion declaration is scoped on a local variable. A scoped local has narrower lifetime than usual, which is useful when you want a ref to be confined to a tight block.

In this case scoped is mostly documentation, but it also forbids any pattern that would try to let tightView escape further than bigBuffer itself, which is the standard safety check the compiler runs.

The diagram shows the runtime picture. The caller allocates a stack buffer and a span over it. When the caller invokes a method whose parameter is scoped, the compiler knows the span cannot escape that call. The method can read from the span, slice it, pass slices into deeper calls, but it cannot return the span or store it in a ref struct that flows back out.

A useful pattern is to mark parameters scoped whenever the method only reads or processes the span without retaining it. That's most methods. Parameters that genuinely need to return a slice of the input (like Span<T>.Slice) can leave the modifier off.

ref Fields in ref structs (C# 11)

Before C# 11, a ref struct could hold references to managed memory only through Span<T> and ReadOnlySpan<T>. The compiler had special knowledge about those two types. C# 11 generalized this with ref fields: any ref struct can now declare a field of type ref T, which is a managed reference to a T somewhere else in memory.

This is how Span<T> is implemented now. It used to use a ByReference<T> internal helper; now it's just a ref T field plus a length.

For application code, ref fields let you build small zero-allocation helpers that hold direct references to mutable state.

The Counter holds a ref int that points directly at the caller's orderCount. Incrementing through the counter mutates the original. No heap allocation is involved; the counter is a ref struct on the stack, holding a managed pointer to another stack location.

The ref field syntax is opt-in. You write ref T as the field type, and you assign it with the = ref operator (not just =, which would copy the value). The compiler tracks the field's lifetime as part of the ref-safety analysis, so the Counter can never outlive orderCount.

For most application code, you don't write ref fields directly. They're a primitive the BCL and high-performance libraries use to build types like Span<T>, ReadOnlySpan<T>, and Ref<T>. The lesson here is just that the underlying machinery exists, and the rules you see on ref structs apply to the fields too.

A Practical E-Commerce Example: Parsing Price Tokens

Time to put the pieces together. Suppose you're processing a stream of price tokens from a CSV feed: "$29.99", "$1499.00", "$0.50", and so on. You want to compute the sum without allocating any short-lived strings.

Three things to notice. First, token.AsSpan() is a zero-cost view over the existing string. Second, view.Slice(1) doesn't copy the characters; it just shifts the pointer. Third, decimal.TryParse accepts a ReadOnlySpan<char> overload that parses directly without building a substring. The whole loop runs without producing GC pressure beyond what was already on the heap before the call.

The scoped modifier on the parameter declares that this method doesn't smuggle the input out. Callers can pass any kind of span (heap-backed, stack-backed, slice of a buffer) without worrying.

Now extend the same idea to a stack buffer. Suppose you're processing order IDs that arrived as a contiguous buffer of bytes (say, four-byte little-endian integers). You can read them into a Span<int> without any heap allocation.

The 16-byte buffer lives on the stack. The Span<byte> is a safe handle to it. The method reads four 4-byte integers without copying anything. Nothing is allocated on the heap. Nothing creates GC pressure. The whole thing tears down for free when the method returns.

When to Reach for a ref Struct

Ref structs are a niche tool. Most application code should use regular structs, classes, and records, because the restrictions are real and they bite. But there are several patterns where a ref struct is the right answer:

Use CaseWhy a ref Struct Fits
Parsing without allocationReadOnlySpan<char> slicing is free, and the ref struct rule means the slice can't outlive the source string.
Processing fixed buffersstackalloc returns a Span<T>, the only safe way to expose stack memory.
Zero-allocation iterationCustom enumerators on ref structs can avoid the boxing that IEnumerator<T> would force.
Wrapping native pointersInterop scenarios where the wrapper must not leak into managed long-lived state.
Holding refs to mutable stateC# 11 ref fields let you build small helpers that mutate caller state without boxing.

What a ref struct is not good for: domain models, anything stored in collections, anything used across await, anything that flows through a generic API that doesn't have allows ref struct. The restrictions are designed to keep you safe in the niche, not to make ref structs the default.

A useful rule of thumb: if you find yourself trying to convince the compiler to let you put a ref struct somewhere, the compiler is right and you should rethink the design. The restrictions exist because the alternative is undefined behavior on stack-allocated memory, which is a class of bug that's near-impossible to debug after the fact.

The _Record Struct_ lesson covers record struct, which is a regular struct with compiler-generated equality and with expressions. It's a different tool for a different job; if you want value semantics with structural equality and you don't need stack-only behavior, record structs are usually the better choice.

One more pattern worth calling out: zero-allocation enumerators. The BCL's collection types sometimes return a struct enumerator (not boxed IEnumerator<T>) when you call foreach on them, which avoids one heap allocation per iteration. A ref struct enumerator goes further: it can carry a Span<T> over the collection's storage, eliminating any indirection at all. You'll see this shape in Span<T>.Enumerator, which is itself a ref struct. Writing custom enumerators in this style is rare in application code, but if you ever profile a hot loop and see significant time in IEnumerator<T>.MoveNext, it's a knob you can reach for.

Summary

  • A ref struct is a struct that the compiler forbids from ever escaping to the heap. It exists so types like Span<T> and ReadOnlySpan<T> can safely wrap stack memory and managed pointers without risk of dangling references.
  • The stack-only rule produces six concrete restrictions: no boxing, no field of a non-ref struct or class, no async/iterator bodies, no lambda capture, no interface implementation (with the C# 13 carve-out), and no generic type argument (with the C# 13 allows ref struct carve-out).
  • Span<T> and ReadOnlySpan<T> are both readonly ref struct in the BCL. They give you zero-allocation slicing over arrays, strings, and stackalloc buffers, which is what makes high-performance parsing and buffer processing possible.
  • scoped (C# 11) narrows a ref struct parameter's escape scope to the call itself, both tightening the contract and letting callers pass shorter-lived spans without compile errors.
  • ref fields inside ref structs (C# 11) generalize what Span<T> does: any ref struct can now hold a ref T to memory elsewhere, opening room for small zero-allocation helpers.
  • Compiler errors to recognize: CS0029 (boxing), CS8345 (ref struct field of a class), CS4012/CS4013 (async/iterator), CS8175 (lambda capture), CS0306 (generic argument).
  • Reach for a ref struct when you're holding a reference into stack or buffer memory and need the compiler to prove the reference can't escape. For domain models, DTOs, and most application code, regular classes and structs are the right answer.