Writing ref structs: rules, ref fields, C# 13 interface and generic support, and a stack-first builder example.
A ref struct is a struct that must live on the stack, so it can safely contain spans and ref fields. Rules: it cannot be boxed, stored in a class or normal struct field, captured by a lambda, used as an array element, or (before C# 13) used as a generic argument or implement interfaces. C# 11 added ref fields. C# 13 lets ref structs implement interfaces (but never convert to them), adds the allows ref struct anti-constraint so generic code can accept them without boxing, and allows ref struct locals in async and iterator methods as long as they are not used across await or yield. A ref struct with a public Dispose() works with using even without an interface. You write one when you need a stack-first helper over spans, such as a string builder that starts with a stackalloc buffer and rents from ArrayPool only if it grows.
A ref struct is a value type that must live on the stack. That restriction lets it safely hold managed references (ref fields) into the stack, arrays or native memory — which is how Span<T>, ReadOnlySpan<T>, Utf8JsonReader and ValueStringBuilder work. I'd cover the rules, ref fields, C# 13 changes, the Dispose pattern, and walk through a ValueStringBuilder-style type.
A ref struct:
object, ValueType, or — before C# 13 — interfaces).ref struct).await or yield (C# 13 allows them in async/iterator methods if they don't cross a suspension point).These rules guarantee a ref struct never outlives the stack frame of the memory it may reference.
ref fields (C# 11)public readonly ref struct Slice<T>
{
private readonly ref T _first;
private readonly int _length;
public Slice(ref T first, int length) { _first = ref first; _length = length; }
public int Length => _length;
}
Only ref structs can declare ref fields. Combined with scoped and the updated safety rules, they let you build span-like abstractions in C# (the BCL's Span<T> now uses a real ref T field).
ref structs can implement interfaces (e.g. IDisposable, IEnumerator<T>), but still can't be converted to the interface (no boxing). The interfaces are usable via generics.allows ref struct anti-constraint: generic methods/types can opt in to accepting ref structs: void Process<T>(T value) where T : allows ref struct. The compiler then enforces ref-struct rules for T inside. .NET 9 annotated many BCL generics (Func<>, Action<>, some collections/comparers) so spans can flow through them.ref struct locals (e.g. Span<T>) as long as they're not used across await/yield.ref structs can't implement IDisposable usefully in older versions, so using is pattern-based: any ref struct with an accessible void Dispose() method works in a using statement/declaration. Typical use: returning a rented ArrayPool buffer.
ValueStringBuilder-style typepublic ref struct ValueStringBuilder
{
private char[]? _rented;
private Span<char> _buffer;
private int _length;
public ValueStringBuilder(Span<char> initialBuffer)
{
_rented = null;
_buffer = initialBuffer;
_length = 0;
}
public void Append(char c)
{
if (_length == _buffer.Length) Grow(1);
_buffer[_length++] = c;
}
public void Append(scoped ReadOnlySpan<char> s)
{
if (_length + s.Length > _buffer.Length) Grow(s.Length);
s.CopyTo(_buffer[_length..]);
_length += s.Length;
}
public void Append<T>(T value, ReadOnlySpan<char> format = default) where T : ISpanFormattable
{
int written;
while (!value.TryFormat(_buffer[_length..], out written, format, CultureInfo.InvariantCulture))
Grow(16);
_length += written;
}
private void Grow(int additional)
{
var newArray = ArrayPool<char>.Shared.Rent(Math.Max(_buffer.Length * 2, _length + additional));
_buffer[.._length].CopyTo(newArray);
var toReturn = _rented;
_buffer = _rented = newArray;
if (toReturn is not null) ArrayPool<char>.Shared.Return(toReturn);
}
public override string ToString()
{
var result = new string(_buffer[.._length]);
Dispose();
return result;
}
public void Dispose()
{
var toReturn = _rented;
this = default; // prevent double return / use after dispose
if (toReturn is not null) ArrayPool<char>.Shared.Return(toReturn);
}
}
// Usage: starts on the stack, grows into pooled arrays only if needed
var sb = new ValueStringBuilder(stackalloc char[256]);
sb.Append("Order "); sb.Append(orderId); sb.Append(" total "); sb.Append(total, "C2");
string text = sb.ToString();
Why it's a ref struct: it holds a Span<char> that may point to stack memory (stackalloc), so it must not escape. The BCL uses an internal ValueStringBuilder like this for zero-allocation formatting; DefaultInterpolatedStringHandler is a public ref struct built on the same idea.
SequenceReader-style).Not for business logic, async workflows, or anything that needs to be stored or passed through interfaces.
A high-volume CSV export built each line with StringBuilder and ToString() per row. We introduced a ValueStringBuilder starting from stackalloc char[512], formatting numbers with TryFormat and writing the resulting span directly to a StreamWriter (no intermediate string). Allocations per row dropped to zero for typical rows, and the rare long rows fell back to pooled arrays. We added tests for double-dispose and growth paths because bugs in such types corrupt data silently.
The interviewer checks deep knowledge of stack-only types and recent language changes.
Green flags
allows ref struct and async/iterator relaxations.Red flags
A ref struct may contain managed pointers to stack memory. The GC tracks managed pointers only in stack frames/registers (and within other ref structs on the stack), not in heap objects. Placing such a struct on the heap (boxing, class fields, closures, async state machines) could create pointers that outlive their targets — the compiler forbids every such path.
The compiler tracks a safe-context for each ref struct value: whether it may reference current-method stack memory. Rules prevent:
stackalloc memory,scoped parameters (C# 11) and [UnscopedRef] fine-tune these rules. In ValueStringBuilder.Append(scoped ReadOnlySpan<char> s), scoped tells the compiler s won't be stored in the builder.
allows ref structpublic static void Use<T>(T value) where T : IFormattable, allows ref struct => value.ToString(null, null);
Inside, T can't be boxed, stored in fields, captured, etc. The constraint enables generic code like Func<ReadOnlySpan<char>, int> in .NET 9.
Custom handlers ([InterpolatedStringHandler] public ref struct MyHandler) receive literal/formatted parts and write into a buffer without allocating — they must be ref structs so they can hold spans over stack buffers.
Span<T> in a class field and capturing a ref struct in a lambda; read the errors.ValueStringBuilder and benchmark against StringBuilder with [MemoryDiagnoser].allows ref struct and call it with ReadOnlySpan<char>.Dispose method in a using declaration.using System.Buffers;
public interface ITextSink
{
void Append(scoped ReadOnlySpan<char> text); // scoped: callers may pass stack memory
}
// Stack-first string builder: C# 13 lets a ref struct implement an interface
public ref struct ValueStringBuilder : ITextSink
{
private Span<char> _buffer;
private char[]? _rented;
private int _length;
public ValueStringBuilder(Span<char> initialBuffer)
{
_buffer = initialBuffer;
_rented = null;
_length = 0;
}
public readonly int Length => _length;
// scoped: the text is copied, never stored, so stackalloc arguments are allowed
public void Append(scoped ReadOnlySpan<char> text)
{
if (_length + text.Length > _buffer.Length) Grow(text.Length);
text.CopyTo(_buffer[_length..]);
_length += text.Length;
}
public void Append(int value)
{
Span<char> digits = stackalloc char[11];
value.TryFormat(digits, out int written);
Append(digits[..written]);
}
private void Grow(int needed)
{
char[] next = ArrayPool<char>.Shared.Rent(Math.Max(_buffer.Length * 2, _length + needed));
_buffer[.._length].CopyTo(next);
if (_rented is not null) ArrayPool<char>.Shared.Return(_rented);
_rented = next;
_buffer = next;
}
public override string ToString()
{
string result = _buffer[.._length].ToString();
Dispose();
return result;
}
public void Dispose() // pattern-based: usable with 'using' without IDisposable
{
if (_rented is not null)
{
ArrayPool<char>.Shared.Return(_rented);
_rented = null;
}
_buffer = default;
_length = 0;
}
}
public static class Csv
{
// C# 13: generic code that accepts ref structs without boxing
public static void WriteRow<TSink>(ref TSink sink, scoped ReadOnlySpan<string> fields)
where TSink : ITextSink, allows ref struct
{
for (int i = 0; i < fields.Length; i++)
{
if (i > 0) sink.Append(",");
sink.Append(fields[i]);
}
}
}
public static class Program
{
public static void Main()
{
var builder = new ValueStringBuilder(stackalloc char[16]); // starts on the stack
Csv.WriteRow(ref builder, ["Mia", "Sydney", "NSW"]);
builder.Append(",");
builder.Append(2000);
builder.Append(", a much longer trailing field that forces a rented buffer");
Console.WriteLine(builder.Length);
Console.WriteLine(builder.ToString()); // returns any rented array
}
}
What happens: domain types as ref structs "for performance". Why it is wrong: severe restrictions (no async, no storage) for little benefit. Do instead: reserve ref structs for low-level buffer types.
What happens: Dispose() called twice or ToString() + Dispose() both return the buffer.
Why it is wrong: the array enters the pool twice — corruption.
Do instead: reset state (this = default) after returning.
What happens: appending after the buffer was returned. Why it is wrong: writes into memory owned by someone else. Do instead: clear spans on dispose and guard usage.
stackalloc initial buffersWhat happens: stackalloc char[64_000].
Why it is wrong: stack overflow risk.
Do instead: small initial buffers, pooled growth.
scoped on span parametersWhat happens: compiler errors or forcing callers to heap allocate.
Why it is wrong: ref safety rules can't prove non-escape.
Do instead: mark non-escaping parameters scoped.
What happens: copying a builder struct into helper methods.
Why it is wrong: changes to the copy are lost (length not updated).
Do instead: pass by ref.
What happens: casting a ref struct to IDisposable.
Why it is wrong: boxing is forbidden.
Do instead: use pattern-based using or generics with allows ref struct.
What happens: TryFormat with current culture for machine output.
Why it is wrong: inconsistent output across servers.
Do instead: pass CultureInfo.InvariantCulture for data formats.
Ref structs are the foundation of allocation-free .NET: Span<T>, Utf8JsonReader, SequenceReader<T> and interpolated string handlers are all ref structs. Writing your own is a library-level skill.
| Situation | Choice |
|---|---|
| Hot builder or parser over spans | custom ref struct |
| General string building | StringBuilder or string.Create |
| Generic algorithms that should accept spans | allows ref struct constraints (C# 13) |
| State needed across await | class or Memory<T>-based type |
The senior judgement is about ownership and copying: a ref struct holding a rented array has value semantics but owns a resource, which is an unusual combination. Document it, pass it by ref, and test disposal paths.
A struct is a value type: a variable holds the data itself, assignment and parameter passing copy it, and it is stored inline (in a local, a field or an array element) rather than as a separate heap object. Structs cannot inherit (they are sealed) but can implement interfaces. default(T) always gives all-zero fields, even when you write a parameterless constructor (allowed since C# 10). readonly struct guarantees immutability and avoids defensive copies. ref struct (like Span<T>) can live only on the stack; since C# 13 it can implement interfaces and be used with allows ref struct generics. Converting a struct to object or an interface boxes it. Mutable structs cause lost updates because you often change a copy.
Span<T> is a ref struct holding a reference and a length: a typed, bounds-checked view over contiguous memory, whether that memory is an array, a stackalloc buffer, native memory or part of a string (through ReadOnlySpan<char>). Slicing (span[2..6], Slice) creates a new view without copying, so parsers and formatters can work on pieces of a buffer with no allocations. Because it can point at the stack, it is stack-only: it cannot be a field of a class, be boxed, be captured by a lambda, or live across await/yield (C# 13 allows span locals in async and iterator methods as long as they do not cross those points). Helpers in MemoryExtensions (IndexOf, SequenceEqual, Sort, Trim) are vectorised. CollectionsMarshal.AsSpan(list) exposes a list's backing array, but adding to the list invalidates it.
Constraints tell the compiler what T can do so you can use it safely. C# has class and class? for reference types, struct for non-nullable value types, unmanaged for blittable structs, notnull, new() for a public parameterless constructor, base-class and interface constraints, where T : U, and Enum and Delegate constraints. C# 13 added the allows ref struct anti-constraint so T can be a Span<T>-like type. Interface constraints on struct T also avoid boxing because the JIT emits constrained calls.
How to tell it (STAR)
**Situation:** our [nightly CSV export] took [25] minutes and hammered the GC. **Task:** I wanted to speed it up without changing the output format. **Action:** profiling showed a StringBuilder per row, so I wrote a small ref struct builder that starts with a stack buffer and only rents from ArrayPool for long rows, and added benchmarks and tests comparing output byte for byte. **Result:** allocations per row fell by [about 80%], the job dropped to [11] minutes, and the GC graph went almost flat.
Last updated 30 Oct 2026.