Mert Özen Further With Every Line
Backend Advanced C# — #09

ref Returns, ref Locals, in Parameters and ref struct in C#

Mert Özen Nov 7, 2026 10 min 8 views
ref Returns, ref Locals, in Parameters and ref struct in C#

Moving values by reference instead of copying them: ref locals, ref returns, ref readonly, the defensive-copy trap with in parameters, readonly structs and writing your own ref struct.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

BookStock is a struct holding an ISBN and a stock count. We take the first book in the array into a variable and lower its stock by one, then take the second book with ref and lower that one too. Which two numbers appear on the screen?

var books = new BookStock[]
{
    new("978-0441013593", 5),
    new("978-0141439518", 3),
};

BookStock copy = books[0];
copy.Stock--;

ref BookStock slot = ref books[1];
slot.Stock--;

Console.WriteLine($"{books[0].Stock} {books[1].Stock}");

public struct BookStock
{
    public BookStock(string isbn, int stock)
    {
        Isbn = isbn;
        Stock = stock;
    }

    public string Isbn { get; }
    public int Stock { get; set; }
}

ref Locals: The Thing Itself, Not a Copy

Output:

5 2

The first book's stock is still 5. A struct is a value type; BookStock copy = books[0] took a copy of the value in the array, and copy.Stock-- changed only the copy. On the second line we wrote ref BookStock slot = ref books[1]. slot isn't a new value; it's another name given to the array's second cell itself. Whatever you do to it happens directly to that cell; the stock went from 3 to 2.

Think of a ref local as an alias. A normal variable is a box, and assignment copies the contents into a new box. A ref local is a label pointing at the box itself; the box can be an array cell, a field or another variable. This has been around since C# 7.0 and it's valuable in two places: not copying large structs, and changing a value where it lives.

Notice the ref on both sides of the assignment. The ref books[1] on the right says "give me a reference to this cell", the ref BookStock on the left says "I'll hold a reference". Forget either one and the compiler gives you an error; it doesn't quietly fall back to a copy.

ref Returns: Handing the Caller an Address

The real power of ref is that you can return it from a method. Think of an inventory class that keeps books in a struct array internally and searches by ISBN. If it returns the record by value, the caller only gets a copy. If it returns a reference, the caller can update the record in place:

var inventory = new Inventory(
    new BookStock("978-0441013593", 5),
    new BookStock("978-0141439518", 3));

ref BookStock dune = ref inventory.Find("978-0441013593");
dune.Stock -= 2;

inventory.Find("978-0141439518").Stock += 10;

foreach (BookStock item in inventory.All)
    Console.WriteLine($"{item.Isbn}: {item.Stock}");

public sealed class Inventory
{
    private readonly BookStock[] _items;

    public Inventory(params BookStock[] items) => _items = items;

    public ReadOnlySpan<BookStock> All => _items;

    public ref BookStock Find(string isbn)
    {
        for (int i = 0; i < _items.Length; i++)
        {
            if (_items[i].Isbn == isbn)
                return ref _items[i];
        }

        throw new KeyNotFoundException(isbn);
    }
}

public struct BookStock
{
    public BookStock(string isbn, int stock)
    {
        Isbn = isbn;
        Stock = stock;
    }

    public string Isbn { get; }
    public int Stock { get; set; }
}
978-0441013593: 3
978-0141439518: 13

Find's return type is ref BookStock, and return ref _items[i] hands out a reference to the array cell. The caller can bind it to a ref local with ref BookStock dune, or, as in the second use, change it directly without putting it in a variable at all: inventory.Find(...).Stock += 10. No copies; both updates happened inside the array.

The All property is familiar from the previous post: it exposes the array as a ReadOnlySpan<BookStock>. The caller can iterate but can't change anything. A span's indexer is actually a ref return too: Span<T>'s this[int] returns ref T, which is why span[0] = 5 or span[0].Stock++ changes the underlying memory directly.

What the Compiler Lets You Return

A returned reference has to point at something that lives on after the method ends. A method's own local variable is gone when the method ends; returning a reference to it would mean handing out an address that has been emptied. The compiler catches this at compile time:

static ref int Broken()
{
    int stock = 5;
    return ref stock;
}

static ref int AlsoBroken(int[] items)
{
    var copy = items[0];
    return ref copy;
}

Console.WriteLine(Broken());
Console.WriteLine(AlsoBroken(new[] { 1 }));
Program.cs(4,16): error CS8168: Cannot return local 'stock' by reference because it is not a ref local
Program.cs(10,16): error CS8168: Cannot return local 'copy' by reference because it is not a ref local

The second example is interesting: the items array belongs to the caller, so it's safe. But var copy = items[0] copied the element into a local, and what we're trying to return is now that local. The correct version is return ref items[0];. Roughly, the rule is: you can return a reference to a field, an array element, a ref parameter, or a ref local derived from one of those; you can't return a reference to the method's own local. The C# specification defines this analysis as the "ref safe context" rules, and these rules are why spans are safe.

ref readonly: Reading Allowed, Writing Not

Sometimes the reason to return a reference isn't to change it but to avoid copying. If you want to hand out a large struct without a copy but not let the caller change it, you return ref readonly:

var prices = new PriceTable(new[] { 9.99m, 12.50m });

ref readonly decimal first = ref prices.At(0);
Console.WriteLine(first);

first = 0m;
prices.At(1) = 0m;

public sealed class PriceTable
{
    private readonly decimal[] _prices;

    public PriceTable(decimal[] prices) => _prices = prices;

    public ref readonly decimal At(int index) => ref _prices[index];
}
Program.cs(6,1): error CS0131: The left-hand side of an assignment must be a variable, property or indexer
Program.cs(7,1): error CS8331: Cannot assign to method 'At' or use it as the right hand side of a ref assignment because it is a readonly variable

The caller can read the value without a copy through ref readonly decimal first, but can't write through either the local or the method call; neither assignment compiled. This is the classic way to expose a collection's internals. ReadOnlySpan<T>'s indexer also returns ref readonly T.

in Parameters and the Hidden Copy

On the parameter side, ref comes with three keywords: ref (reads and writes), out (must write) and in, which came with C# 7.2. in passes the argument by reference but read-only. The idea is to avoid copying a large struct on every call:

using System.Globalization;

var order = new OrderTotals(1200m, 18m, 50m, 25m);
Console.WriteLine(GrandTotal(in order).ToString(CultureInfo.InvariantCulture));
Console.WriteLine(GrandTotal(order).ToString(CultureInfo.InvariantCulture));

static decimal GrandTotal(in OrderTotals t)
{
    return t.WithTax() + t.Shipping - t.Discount;
}

public readonly struct OrderTotals
{
    public OrderTotals(decimal subtotal, decimal taxRate, decimal shipping, decimal discount)
    {
        Subtotal = subtotal;
        TaxRate = taxRate;
        Shipping = shipping;
        Discount = discount;
    }

    public decimal Subtotal { get; }
    public decimal TaxRate { get; }
    public decimal Shipping { get; }
    public decimal Discount { get; }

    public decimal WithTax() => Subtotal * (1 + TaxRate / 100);
}
1441.00
1441.00

OrderTotals carries four decimals, that is 64 bytes. Passed with in, it isn't copied; only the reference goes over. Writing in at the call site is optional; the second call goes to the same method. If you try to assign to the parameter inside the method, the compiler stops you:

var order = new OrderTotals(1200m, 18m, 50m, 25m);
Reset(in order);

static void Reset(in OrderTotals t)
{
    t = default;
}

public readonly struct OrderTotals
{
    public OrderTotals(decimal subtotal, decimal taxRate, decimal shipping, decimal discount)
    {
        Subtotal = subtotal;
        TaxRate = taxRate;
        Shipping = shipping;
        Discount = discount;
    }

    public decimal Subtotal { get; }
    public decimal TaxRate { get; }
    public decimal Shipping { get; }
    public decimal Discount { get; }
}
Program.cs(6,5): error CS8331: Cannot assign to variable 't' or use it as the right hand side of a ref assignment because it is a readonly variable

One detail is critical here: I made OrderTotals a readonly struct. Let's see why that matters with an ordinary struct:

var counter = new Counter();
Console.WriteLine(Use(in counter));
Console.WriteLine(Use(in counter));
Console.WriteLine(counter.Value);

counter.Next();
Console.WriteLine(counter.Value);

static int Use(in Counter c)
{
    c.Next();
    return c.Value;
}

public struct Counter
{
    public int Value { get; private set; }

    public void Next() => Value++;
}
0
0
0
1

We called c.Next() inside Use, yet the value stayed 0 and the original counter didn't change either. The compiler had promised that an in parameter won't change. Since Counter isn't readonly, it can't know whether Next() modifies the struct; to keep its promise, it silently makes a copy before the call and calls Next() on that copy. This is called a defensive copy. The behavior is surprising, and the copy you were trying to get rid of with in comes back on every method call.

The fix is to make structs you pass with in readonly structs. Then the compiler knows no member changes the state and makes no copy. If you can't make the whole struct readonly, since C# 8 you can mark individual members readonly. The practical rule: don't use in for small structs (a few fields), passing them by value is already cheap; use it for large, readonly structs.

ref struct: Your Own Span-Like Type

In the previous post we saw that a span can't sit in a class field. A ref struct can have one, though, because a ref struct is under the same rules: it lives only on the stack. This is how you write small, allocation-free helper types that wrap spans:

var tokens = new FieldReader("978-0441013593;Dune;9.99");

while (tokens.TryRead(out ReadOnlySpan<char> field))
    Console.WriteLine($"[{field.ToString()}]");

public ref struct FieldReader
{
    private ReadOnlySpan<char> _rest;
    private bool _done;

    public FieldReader(ReadOnlySpan<char> text)
    {
        _rest = text;
        _done = false;
    }

    public bool TryRead(out ReadOnlySpan<char> field)
    {
        if (_done)
        {
            field = default;
            return false;
        }

        int sep = _rest.IndexOf(';');
        if (sep < 0)
        {
            field = _rest;
            _done = true;
            return true;
        }

        field = _rest[..sep];
        _rest = _rest[(sep + 1)..];
        return true;
    }
}
[978-0441013593]
[Dune]
[9.99]

FieldReader splits the line into fields without touching the heap and hands out each field as a span. All of span's restrictions apply to it as well: it can't be a field of a class, can't be boxed, can't be captured in a lambda, can't be a local in an async method in C# 10, can't implement an interface and can't be a generic type argument. The last two were relaxed in C# 13 (allows ref struct); C# 11 brought ref fields and the scoped keyword to ref structs. We'll look at those in the C# Versions series.

Common Pitfalls

Trying to change a struct inside a list. An array's indexer gives you the cell by reference, but List<T>'s indexer is a normal property and returns a copy of the value. Changing a copy is pointless, so the compiler doesn't allow it:

var stock = new List<BookStock> { new("978-0441013593", 5) };
stock[0].Stock--;

public struct BookStock
{
    public BookStock(string isbn, int stock)
    {
        Isbn = isbn;
        Stock = stock;
    }

    public string Isbn { get; }
    public int Stock { get; set; }
}
Program.cs(2,1): error CS1612: Cannot modify the return value of 'List<BookStock>.this[int]' because it is not a variable

The fixes: take the element into a variable, change it, write it back (var s = stock[0]; s.Stock--; stock[0] = s;); or, on a hot path, reach the list's internal array as a span with CollectionsMarshal.AsSpan. But that second route has its own pitfall:

using System.Runtime.InteropServices;

var stock = new List<BookStock>(capacity: 2)
{
    new("978-0441013593", 5),
    new("978-0141439518", 3),
};

foreach (ref BookStock item in CollectionsMarshal.AsSpan(stock))
    item.Stock += 100;

Console.WriteLine($"{stock[0].Stock} {stock[1].Stock}");

ref BookStock first = ref CollectionsMarshal.AsSpan(stock)[0];
stock.Add(new BookStock("978-0316769488", 7));
Console.WriteLine(stock.Capacity);
first.Stock = 0;
Console.WriteLine($"{stock[0].Stock} {first.Stock}");

public struct BookStock
{
    public BookStock(string isbn, int stock)
    {
        Isbn = isbn;
        Stock = stock;
    }

    public string Isbn { get; }
    public int Stock { get; set; }
}
105 103
4
105 0

With foreach (ref BookStock item in ...) we updated every element in place; no problem so far. Then we took a ref to the first element and added an element to the list. The capacity of 2 was full, so the list moved to a new internal array with a capacity of 4. first still points at the old array; the 0 we wrote through it never reached the list. No error, no exception, just a lost update. The rule: a span or ref you get from CollectionsMarshal.AsSpan is valid only as long as the list doesn't change. The same goes for a ref you return from any class that can replace its internal array.

Putting ref everywhere. Ref locals and ref returns hurt readability: someone reading the code has to think about whether each assignment is a copy or an alias. There's no gain for small structs or for classes (they're already reference types). These tools answer a measured performance problem or a need for in-place updates; they aren't a default style.

A Small Experiment

★☆☆ In the opening example, change the copy line to ref BookStock copy = ref books[0];. What's the output? Then remove the ref on the right-hand side and read the compiler error.

★★☆ Make Counter a readonly struct. What does the compiler say about Next()? Rewrite Next() as public Counter Increment(), which returns a new Counter, and fix Use accordingly.

★★★ Add an Add method to Inventory; when the array is full, it should copy into a new array twice the size. Then take a ref with Find, call Add, and change the stock through the ref. Is the update lost? How would you prevent this risk in the API design (for example with an Update(string isbn, Func<BookStock, BookStock> change) method instead of a ref)?

With refs we moved around inside managed memory without copying, and the compiler checked every step. In the next post we step to the edge of that safe area: P/Invoke for calling native libraries, pointers and unsafe code.