Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C# & .NET (C# 14, .NET 10 LTS)  Module 17 – Memory, Span and performance

ref structs, ref fields and scoped

Write ref struct types such as a packet reader over a span, use ref fields and scoped parameters, and fix the escape errors the C# compiler reports.

  • Advanced
  • 15 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 6 of 15 minutes free
Free: A ref struct can hold a span because it never leaves the stack · Ref fields and scoped say how far a reference may go. With a pass: 2 in-depth parts and 3 interview answers, about 9 minutes.

What you will learn

  • Write a ref struct that reads data through a span, and work within its restrictions
  • Use ref fields and scoped parameters so that callers may pass stack memory
  • Read and fix the compiler's escape errors, such as CS8352 and CS8350
  • Use ref returns, and accept ref structs in generic code with allows ref struct

Before you start

What you’ll learn · Before you start

What you will learn

  • Write a ref struct that reads data through a span, and work within its restrictions
  • Use ref fields and scoped parameters so that callers may pass stack memory
  • Read and fix the compiler's escape errors, such as CS8352 and CS8350
  • Use ref returns, and accept ref structs in generic code with allows ref struct

Before you start

On this page

A reader that walks through a 12-byte packet must keep the bytes it has not read yet, and to copy nothing it keeps them as a ReadOnlySpan<byte>. A class cannot hold that field: the compiler refuses it with CS8345. Declared as a ref struct instead, the reader below read four values, and a million such readers allocated 0 bytes.

Three features make such types possible: ref structs can hold spans because they never leave the stack, ref fields hold a reference to a variable, and the scoped modifier tells the compiler how far a reference may travel. You will write a ref struct, let callers pass stack memory, fix escape errors and use ref structs in generic code.

A ref struct can hold a span because it never leaves the stack

reader.cs declares PacketReader as a ref struct: A struct declared with ref struct, such as Span<T>. Its instances live only on the stack: it cannot be boxed, be a field of a class, be captured by a lambda or be kept across an await. with a span field, reads a type, a sensor number, a temperature and a battery level from 12 bytes, then builds a million readers over the same array:

reader.cs: reading big-endian values through a span C# · reader/reader.cs
// A ref struct that reads big-endian numbers from a 12-byte packet without copying it.
using System.Buffers.Binary;

byte[] packet = [0x00, 0x2A, 0x00, 0x00, 0x30, 0x39, 0x41, 0xD8, 0x00, 0x00, 0x01, 0xF4];
var reader = new PacketReader(packet);
Console.WriteLine($"type {reader.ReadUInt16()}, sensor {reader.ReadUInt32()}, temperature {reader.ReadSingle()}, battery {reader.ReadUInt16()} mV");
Console.WriteLine($"{reader.Remaining} bytes left, and reading on: {reader.TryReadUInt16(out _)}");

long before = GC.GetAllocatedBytesForCurrentThread();
long sum = 0;
for (int i = 0; i < 1_000_000; i++)
{
    var r = new PacketReader(packet);
    sum += r.ReadUInt16() + r.ReadUInt32();
}
Console.WriteLine($"1,000,000 readers: {GC.GetAllocatedBytesForCurrentThread() - before} bytes allocated (sum {sum})");

ref struct PacketReader(ReadOnlySpan<byte> packet)
{
    private ReadOnlySpan<byte> _rest = packet; // a span as a field: allowed in a ref struct only

    public readonly int Remaining => _rest.Length;

    public ushort ReadUInt16() => TryReadUInt16(out ushort value) ? value : throw new InvalidOperationException("not enough bytes");

    public bool TryReadUInt16(out ushort value)
    {
        value = 0;
        if (_rest.Length < 2) return false;
        value = BinaryPrimitives.ReadUInt16BigEndian(_rest);
        _rest = _rest[2..];
        return true;
    }

    public uint ReadUInt32()
    {
        uint value = BinaryPrimitives.ReadUInt32BigEndian(_rest); // throws if fewer than 4 bytes are left
        _rest = _rest[4..];
        return value;
    }

    public float ReadSingle()
    {
        float value = BinaryPrimitives.ReadSingleBigEndian(_rest);
        _rest = _rest[4..];
        return value;
    }
}

Output

type 42, sensor 12345, temperature 27, battery 500 mV
0 bytes left, and reading on: False
1,000,000 readers: 0 bytes allocated (sum 12387000000)

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run reader.cs

BinaryPrimitives read each value with its most significant byte first, the order network protocols use, and each read moved the span past what it had read. The million readers allocated nothing: every one was a struct on the stack, viewing the same array. Step through the reads:

Interactive · message by message PacketReader walking through the 12-byte packet Step through the reads of reader.cs. The program is on the left, the PacketReader in the middle and the packet's bytes on the right; each read moves the reader's span past what it read.
PacketReader walking through the 12-byte packet, step 6 of 6. Messages so far: reader.cs to PacketReader: new PacketReader(packet); the packet to PacketReader: 00 2A, type 42; the packet to PacketReader: 00 00 30 39, sensor 12345; the packet to PacketReader: 41 D8 00 00, 27; the packet to PacketReader: 01 F4, battery 500; PacketReader to reader.cs: TryReadUInt16: false.reader.csthe programPacketReader_rest: 0 bytes leftthe packet12 bytes in a byte[]new PacketReader(packet)00 2A, type 4200 00 30 39, sensor 1234541 D8 00 00, 2701 F4, battery 500TryReadUInt16: false

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The reader starts. new PacketReader(packet) stores a ReadOnlySpan<byte> over the whole array: a reference to byte 0 and a length of 12. The bytes are not copied, and the reader itself lives on the stack.
  2. Two bytes, big-endian. ReadUInt16 reads bytes 00 2A with the most significant byte first: 0x002A is 42. Then it slices its span past them.
  3. Four bytes. ReadUInt32 reads 00 00 30 39, which is 12345, the sensor number, and moves on four bytes.
  4. A big-endian float. ReadSingle reads 41 D8 00 00 as an IEEE 754 float, which is 27: the temperature.
  5. The last two bytes. ReadUInt16 reads 01 F4, which is 500, the battery in millivolts. The span is now empty.
  6. Nothing left to read. TryReadUInt16 checks the length first and returns false instead of throwing. A million readers built the same way allocated 0 bytes, because each one is a struct on the stack over the same array.

Ref structs pay for this with rules that keep them on the stack, which the compiler enforces:

  • no array of them, and no field of one in a class or in an ordinary struct;
  • no boxing, so no conversion to object or to an interface they implement;
  • no capture by a lambda or a local function;
  • in an async method or an iterator, never alive across an await or a yield return (allowed at all since C# 13);
  • as a type argument only where the type parameter says allows ref struct (also C# 13).

The rules grew version by version, as the C# version history records:

Timeline · 7 events in order How C# grew the features of this lesson Walk through the versions in order. Each one let safe code do more without copying, and each kept the compiler in charge of the rules.
  1. C# 7.0

    ref locals and ref returns

    A method can return a reference to a variable, such as an array element, and a local can be another name for it, so a large struct is used in place instead of copied.

  2. C# 7.2

    ref struct, in and ref readonly returns new kind of type

    ref struct declares a type that must always live on the stack, which made Span<T> possible. in parameters and ref readonly returns pass and return by reference for reading only.

  3. C# 8.0

    Disposable ref structs

    A ref struct with a public Dispose method works with using, although it cannot implement IDisposable yet.

  4. C# 11

    ref fields and scoped new syntax

    A ref struct can hold a ref field, a reference to a variable, and scoped limits how far a parameter or local may travel, so the compiler can accept more calls.

  5. C# 12

    ref readonly parameters

    A parameter can be declared ref readonly: passed by reference, never written, and expected to be a variable rather than a temporary copy.

  6. C# 13

    Interfaces and allows ref struct new rules

    A ref struct can implement interfaces, a generic type parameter can say allows ref struct, and ref locals and ref struct locals may appear in async methods and iterators as long as they do not cross an await or a yield.

  7. C# 14

    Span conversions and scoped lambdas

    Arrays convert to Span<T> and ReadOnlySpan<T> in more places, including extension method receivers, and a simple lambda parameter can be marked scoped without spelling out its type.

The timeline has 7 events, from C# 7.0 to C# 14.

Hex Viewer & File Inspector Look at the bytes of a file or a packet, and check which value a big-endian read of them gives.

Checkpoint

Check: Ref structs and their restrictions

  1. Question 1 of 2 A type must keep a ReadOnlySpan<byte> field. Which declaration can hold it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: ref struct PacketReader

    A span may live only on the stack, so only a type that is itself confined to the stack, a ref struct, may hold one.

  2. Question 2 of 2 Which of these lines does the compiler accept for a PacketReader, a ref struct?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: var copy = reader; (inside the same method)

    Everything that could move a ref struct to the heap is refused: boxing, arrays, class fields, lambda captures and locals alive across an await.

Ref fields and scoped say how far a reference may go

A ref field: A field declared with ref inside a ref struct, added in C# 11: it holds a reference to a variable rather than a value, so writes through it reach that variable. Span<T> is built on one. is a field that refers to a variable instead of holding a value, and only ref structs may declare one; Span<T> itself is built on a ref field. The scoped: A modifier on a parameter or local of a ref struct type, or on a ref variable, which promises that the value does not leave the current method. The compiler checks the promise and lets callers pass shorter-lived memory. modifier works the other way round: it promises that a parameter or local does not leave the current method. scoped.cs uses both:

scoped.cs: a ref field, and a scoped parameter C# · scoped/scoped.cs
// A ref field refers to a variable of its creator, and scoped promises that a parameter does not escape.
int count = 0;
var counter = new Counter(ref count);
counter.Add();
counter.Add();
counter.Add();
Console.WriteLine($"count is {count} after three calls of counter.Add()");

var line = new LineBuilder(new char[64]); // a builder over a heap array, which may outlive this method's stack memory
Span<char> digits = stackalloc char[11];  // stack memory, gone when this method returns
41_250.TryFormat(digits, out int length);
line.Append("total: ");
line.Append(digits[..length]);            // allowed because Append's parameter is scoped
Console.WriteLine(line.ToString());

ref struct Counter
{
    private ref int _count; // a ref field (C# 11): another name for the caller's variable

    public Counter(ref int count) => _count = ref count;

    public void Add() => _count++;
}

ref struct LineBuilder(Span<char> buffer)
{
    private readonly Span<char> _buffer = buffer;
    private int _length;

    // scoped: Append promises not to keep text, so callers may pass memory that lives shorter than the builder.
    public void Append(scoped ReadOnlySpan<char> text)
    {
        text.CopyTo(_buffer[_length..]);
        _length += text.Length;
    }

    public override readonly string ToString() => _buffer[.._length].ToString();
}

Output

count is 3 after three calls of counter.Add()
total: 41250

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run scoped.cs

Counter keeps a ref field to the local count, so three calls of Add changed the local itself. LineBuilder writes into a heap array, and its caller wanted to append digits from stack memory. Without scoped on text, the compiler would have to assume that Append might keep the span inside the builder, which lives longer than the stack memory, and it would refuse the call. With scoped, Append promises not to keep it, the compiler checks that promise inside Append, and the call compiles.

The compiler reasons about how far each reference may travel, which the C# documentation calls its ref safe context: How far a reference to a variable may travel: for a local, the block that declares it; for a ref or in parameter or a field of a class, back to the caller. A reference may be returned or stored only within it., and for a span or another ref struct value its safe context:

What the reference refers to How far it may travel
a local variable the block that declares it
a ref or in parameter, a field of a class back to the caller
a span over stackalloc memory the method that allocated it
a scoped parameter the method itself

A reference may be returned or stored only where it cannot outlive what it refers to; every escape error is this one rule.

Checkpoint

Check: Ref fields and the scoped modifier

  1. Question 1 of 2 In scoped.cs, Counter keeps a ref field to the local count. What does count hold after counter.Add() is called three times?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 3, because the ref field is another name for count

    A ref field holds a reference to a variable, so writes through it reach the caller's variable, as a ref parameter does.

  2. Question 2 of 2 Without scoped on Append(ReadOnlySpan<char> text), why would the call line.Append(digits) with stackalloc digits fail?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Append could store text in the builder, which may outlive the stack memory

    Without scoped, the compiler must assume the method keeps its argument, and stack memory cannot be kept by something that lives longer.

Escape errors reproduced, each with its fix Pro: opens with a Pro, Premium or Ultimate pass

The escape rules are easiest to learn from the errors themselves. This part breaks the one rule four times in one program, reads what the compiler says about each, and fixes all four.

In this part:

  • Four escapes in one program, and what the compiler reports
  • How the compiler decides: two contexts for every reference

3 runnable examples (C#, Python) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

ref returns, generics that accept ref structs, and a bit reader to build Pro: opens with a Pro, Premium or Ultimate pass

A ref struct is one tool for avoiding copies; references to ordinary structs and generic code over ref structs are the others. This part measures a ref return against a copy, runs a generic method over a ref struct without boxing it, and ends with a lab.

In this part:

  • Handing out the element itself, measured against a copy
  • A ref struct in generic code, without boxing
  • readonly ref structs, and ref structs that clean up
  • A lab: a bit reader that copies nothing

3 runnable examples (C#) · about 7 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Practise

Test yourself on the rules of ref structs and on what ref fields and scoped change. The questions adapt to your answers, weakest idea first, and each one explains its right answer and every wrong one:

Practice that adapts to you

Practise this lesson

6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 6 What makes a type a ref struct?

    Choose one answer.

    Show the answer: Question 1 of 6

    Answer: It is declared with ref struct, and its instances live only on the stack

    ref struct is a declaration: the compiler then keeps every instance on the stack.

  2. Question 2 of 6 Since C# 13, may an async method use a PacketReader local?

    Choose one answer.

    Show the answer: Question 2 of 6

    Answer: Yes, as long as the reader is not alive across an await

    An await may move the method's locals to the heap, so no ref struct may be alive across one.

  3. Question 3 of 6 A parser must keep its position in a buffer between calls from a long-lived service object. Is a ref struct the right type for the parser state?

    Choose one answer.

    Show the answer: Question 3 of 6

    Answer: No: the state outlives one method, so keep an index or a Memory, and use a ref struct only inside each call

    Ref structs suit work that starts and ends within one call. State that outlives the call needs a type that may live on the heap.

  4. Question 4 of 6 What does the scoped modifier on a parameter promise?

    Choose one answer.

    Show the answer: Question 4 of 6

    Answer: That the parameter does not leave the method: it is not returned or stored anywhere that lives longer

    scoped restricts the lifetime of a value to the current method, as the declaration statements reference puts it.

  5. Question 5 of 6 How far may a span over stackalloc memory travel?

    Choose one answer.

    Show the answer: Question 5 of 6

    Answer: No further than the method that allocated the memory

    A reference may travel only as far as what it refers to lives, and stack memory lives as long as its method.

  6. Question 6 of 6 A ref struct holds a ref field to a local of the method that created it. Can that method return the ref struct?

    Choose one answer.

    Show the answer: Question 6 of 6

    Answer: No: the ref struct would hold a reference to a local that dies when the method returns

    A ref struct with a ref field to a local is limited to that local's method, the same rule as for a span over stack memory.

Interview questions

Fresher · What is a ref struct, and why can it not be boxed?

A ref struct is a struct declared with ref struct, such as Span<T>, whose instances may live only on the stack. That lets it hold references that would be unsafe on the heap: a span over stack memory, or a ref field to a local variable. Boxing would copy the value into an object on the heap, where it could outlive the memory it refers to, so the compiler forbids it, and with it every conversion to object or to an interface. For the same reason a ref struct cannot be a field of a class, an element of an array, captured by a lambda or kept across an await. Since C# 13 it can implement interfaces and be a type argument where the type parameter allows ref struct, but it is still never boxed.

  1. Mid to senior

    Design a type that reads big-endian integers and floats from a received network packet without copying it. Why should it be a ref struct, what can its callers no longer do with it, and how does it report a packet that is too short?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Senior to staff

    A ref struct builder writes into a heap buffer, and callers get CS8350 when they append text from a stackalloc buffer. Explain the compiler's reasoning, how scoped changes the contract, and when scoped is the wrong fix.

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    You maintain a generic helper used with arrays and lists, and a team wants to call it with Span<T> and their own ref struct readers. What does allows ref struct permit, what does it forbid inside the helper, and is adding it a breaking change?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Ref structs live only on the stack, so they can hold spans and ref fields, and the compiler forbids everything that could move them to the heap: boxing, class fields, arrays, lambda captures and locals alive across an await.
  • A reader or builder over a span can be a ref struct and allocate nothing; a million readers in reader.cs allocated 0 bytes.
  • Ref fields refer to a variable instead of holding a value, and the scoped modifier promises that a parameter does not leave the method, which lets callers pass stack memory.
  • Every escape error is one rule: a reference may be returned or stored only where it cannot outlive what it refers to.

Terms in this lesson

ref field
A field declared with ref inside a ref struct, added in C# 11: it holds a reference to a variable rather than a value, so writes through it reach that variable. Span<T> is built on one.
ref safe context
How far a reference to a variable may travel: for a local, the block that declares it; for a ref or in parameter or a field of a class, back to the caller. A reference may be returned or stored only within it.
ref struct
A struct declared with ref struct, such as Span<T>. Its instances live only on the stack: it cannot be boxed, be a field of a class, be captured by a lambda or be kept across an await.
scoped
A modifier on a parameter or local of a ref struct type, or on a ref variable, which promises that the value does not leave the current method. The compiler checks the promise and lets callers pass shorter-lived memory.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.