ref locals, ref returns, ref fields (C# 11), scoped, ref safety and practical uses.
ref creates an alias to a storage location instead of a copy. A ref local (ref int slot = ref array[i];) and a ref return (ref T Find(...)) let you read and write an element in place, which avoids copying large structs and double lookups. Ref fields (C# 11) let a ref struct hold a reference, which is how Span<T> is implemented. The compiler enforces ref safety: you cannot return a reference to a local or to a stack-only value that will die, and scoped marks a ref or span parameter as not escaping the method, which makes more call patterns legal. CollectionsMarshal.GetValueRefOrAddDefault returns a ref to a dictionary value so "lookup and update" costs one hash. The risk is dangling or stale references: a ref into a list or dictionary is invalid after the collection resizes.
Ref locals and ref returns let you hold and return references to storage (array elements, fields, dictionary values) instead of copies; ref fields (C# 11) let a ref struct store such a reference, which is how Span<T> is built. The compiler's ref safety rules guarantee references never outlive what they point to, with scoped to narrow lifetimes. I'd cover each, CollectionsMarshal.GetValueRefOrAddDefault, when ref returns help performance, and the bugs references to storage can cause.
public struct Pixel { public byte R, G, B, A; }
public sealed class Image(int width, int height)
{
private readonly Pixel[] _pixels = new Pixel[width * height];
public ref Pixel At(int x, int y) => ref _pixels[y * width + x]; // ref return
}
ref Pixel p = ref image.At(10, 20); // ref local: alias for the array element
p.R = 255; // modifies the element in place - no copy
image.At(11, 20).G = 128; // direct in-place update
Without ref, At would return a copy of the struct, and mutations would be lost. ref readonly returns expose without copying and without allowing writes:
public ref readonly Matrix4x4 Transform => ref _transform; // no 64-byte copy per access
public readonly ref struct Window<T>
{
private readonly ref T _start;
private readonly int _length;
public Window(ref T start, int length) { _start = ref start; _length = length; }
public ref T this[int i] => ref Unsafe.Add(ref _start, (uint)i < (uint)_length ? i : throw new IndexOutOfRangeException());
}
Only ref structs can have ref fields (they must stay on the stack). This made Span<T> expressible in C# rather than relying on runtime magic.
scopedThe compiler tracks, for each reference and ref struct value, its safe context: whether it may refer to the current method's stack (function-member scope) or can escape to the caller (caller-context).
return ref local; → CS8168) or to a span over stackalloc.scoped (C# 11) marks a parameter or local as not escaping the method:public static int Sum(scoped ReadOnlySpan<int> values) { ... } // promises not to store/return 'values'
Callers can then pass spans over stack memory, and the method can't leak them. It also relaxes rules for ref struct methods (e.g. Span<T> parameters of ref struct methods default to scoped in some positions).
[UnscopedRef] (C# 11) does the opposite for specific cases (e.g. returning a ref to a field of this in a struct).CollectionsMarshal.GetValueRefOrAddDefaultvar counts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var word in words)
{
ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(counts, word, out _);
count++; // ONE hash lookup, in-place update
}
TryGetValue + indexer set, and avoids copying large struct values.GetValueRefOrNullRef returns a null ref for missing keys (check with Unsafe.IsNullRef).ref T this[int i]), ECS-style component storage.GetValueRefOrAddDefault.MemoryMarshal.GetReference(span) + Unsafe.Add in low-level loops (careful!).For small types (≤ 16 bytes) or reference types, copying is cheap and refs add complexity for little gain. Measure with BenchmarkDotNet.
List<T> backing array (via CollectionsMarshal.AsSpan) or a dictionary entry becomes stale when the collection grows; writes go to the old array and are lost.ref return lets callers mutate private storage, bypassing invariants (use ref readonly).ref readonly.Unsafe.Add, Unsafe.As) bypass bounds and type checks — memory corruption if misused.A simulation service updated millions of Particle structs (48 bytes each) per frame via particles[i] = particles[i] with { ... } — copying each struct twice. Changing the loop to ref var p = ref particles[i]; p.X += p.Vx * dt; (and later iterating a Span<Particle>) removed the copies and roughly doubled frame throughput. Separately, a word-count job switched to GetValueRefOrAddDefault, halving hash lookups. One regression: a helper cached a ref to a List<T> element across an Add that resized the list — a test with a small initial capacity caught it.
The interviewer checks deep knowledge of C# reference semantics and the safety rules behind spans.
Green flags
scoped fixes.GetValueRefOrAddDefault and the stale reference trap.Red flags
ref with passing a class reference.A ref is a managed pointer (T& in IL). It can point into the stack, into an object's field, into an array element, or into unmanaged memory. The GC tracks managed pointers in registers/stack slots ("interior pointers") and updates them when objects move. Managed pointers can't be stored in heap fields (except as ref fields of ref structs, which themselves can't be on the heap).
ref parameters: safe-context = caller (can be returned).scoped ref parameters: safe-context = current method.ref struct values, separate rules govern the values they reference ("ref-safe-to-escape").The compiler ensures any returned ref/ref struct value has caller-context. C# 11 overhauled these rules (with scoped and ref fields), and some older code gets new errors — [UnscopedRef] addresses specific patterns.
public ref T this[int index] => ref _items[index];
Indexers returning ref can't have setters (assignment goes through the ref). ref readonly indexers expose large elements cheaply without mutation. Span<T>'s indexer is a ref-returning indexer.
Unsafe and MemoryMarshalUnsafe.Add(ref T, int) — pointer arithmetic on refs (no bounds check).Unsafe.As<TFrom, TTo>(ref TFrom) — reinterpret.Unsafe.IsNullRef / Unsafe.NullRef<T>() — null refs.MemoryMarshal.GetReference(span) / GetArrayDataReference(array) — refs to first elements.These are the tools behind high-performance BCL code; misuse leads to memory corruption.
ref readonlyCalling non-readonly members on structs via ref readonly/in/readonly fields creates hidden copies. Marking structs and members readonly avoids them. C# 12 added ref readonly parameters (stricter than in, requiring an lvalue).
GetValueRefOrAddDefault and compare with TryGetValue + set.CollectionsMarshal.AsSpan across a list resize and observe stale writes.using System.Runtime.InteropServices;
public struct Particle
{
public double X, Y, Vx, Vy; // 32 bytes: copies add up in hot loops
public int Hits;
}
public ref struct HitCounter
{
private ref int _total; // C# 11 ref field: only allowed in a ref struct
public HitCounter(ref int total) => _total = ref total;
public void Add(int hits) => _total += hits;
}
public static class Program
{
// Ref return into the caller's span: no copy, caller can write through it
static ref Particle Fastest(Span<Particle> particles)
{
ref Particle best = ref particles[0];
for (int i = 1; i < particles.Length; i++)
{
ref Particle p = ref particles[i];
if (p.Vx * p.Vx + p.Vy * p.Vy > best.Vx * best.Vx + best.Vy * best.Vy)
best = ref p; // re-point the ref local
}
return ref best;
}
// scoped: the span does not escape, so callers can pass stackalloc memory freely
static int Total(scoped ReadOnlySpan<int> values)
{
int sum = 0;
foreach (int v in values) sum += v;
return sum;
}
public static void Main()
{
Particle[] particles = new Particle[3];
particles[1].Vx = 5;
particles[2].Vy = 2;
ref Particle fastest = ref Fastest(particles);
fastest.Hits++; // updates particles[1] in place
Console.WriteLine(particles[1].Hits); // 1
int total = 0;
var counter = new HitCounter(ref total);
foreach (ref Particle p in particles.AsSpan()) // ref iteration variable
counter.Add(p.Hits);
Console.WriteLine(total); // 1
var counts = new Dictionary<string, int>();
foreach (string word in "the cat and the hat".Split(' '))
{
ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(counts, word, out _);
count++; // one hash lookup per word
}
Console.WriteLine(counts["the"]); // 2
Span<int> scratch = stackalloc int[] { 1, 2, 3 };
Console.WriteLine(Total(scratch)); // 6
}
}
What happens: refs from CollectionsMarshal used after adding/removing items.
Why it is wrong: stale references after resize; lost writes.
Do instead: use refs immediately; re-acquire after modifications.
What happens: public ref T Get(int i) on domain types.
Why it is wrong: callers bypass invariants.
Do instead: ref readonly or methods that enforce rules.
What happens: ref returns for int or class references.
Why it is wrong: complexity without benefit.
Do instead: reserve refs for large structs and hot paths.
What happens: algorithms take two refs that may point to the same element. Why it is wrong: unexpected results. Do instead: check for aliasing or copy.
Unsafe.AddWhat happens: pointer arithmetic beyond bounds. Why it is wrong: memory corruption without exceptions. Do instead: use bounds-checked spans unless proven necessary.
scoped in APIs taking spansWhat happens: callers can't pass stack spans.
Why it is wrong: forces heap allocations.
Do instead: mark non-escaping span parameters scoped.
What happens: multiple threads mutate shared storage via refs. Why it is wrong: data races. Do instead: synchronise or partition data.
What happens: assigning a ref return to a normal local (var p = image.At(1,2);).
Why it is wrong: that copies the value.
Do instead: use ref var p = ref ....
Ref features are precision tools. They let C# express what C++ developers do with references and pointers while keeping memory safety, and they are why Span<T> can exist in safe code.
| Feature | Where it earns its place |
|---|---|
| ref locals/returns | arrays of large structs, ECS-style data, matrices |
| ref readonly | exposing internal struct data without copies or writes |
| ref fields | custom span-like or writer ref structs in libraries |
| scoped | APIs taking several spans, especially with stackalloc callers |
| GetValueRefOrAddDefault | counting and aggregation hot paths |
In application code I keep them rare and commented, because aliasing makes code harder to reason about. In libraries and hot loops they can remove significant copying and hashing cost, and benchmarks should justify each use.
C# passes everything by value by default. For a value type that copies the data; for a reference type it copies the reference, so the method can change the object but reassigning the parameter does not affect the caller. ref passes the caller's variable itself; out is a ref the callee must assign, used by TryParse; in and ref readonly pass a read-only reference, useful for large structs. params takes a variable number of arguments, and since C# 13 it can be a ReadOnlySpan<T> to avoid allocating. Optional parameters' defaults are compiled into the caller, which matters when versioning public APIs.
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.
Use TryGetValue for reads: one hash lookup, no exception. ContainsKey then dict[key] hashes twice. Add throws ArgumentException on a duplicate key; the indexer setter silently overwrites; TryAdd returns false instead of throwing. The indexer getter throws KeyNotFoundException; GetValueOrDefault returns a default. Pass a comparer such as StringComparer.OrdinalIgnoreCase for case-insensitive keys. For counters and aggregations, CollectionsMarshal.GetValueRefOrAddDefault gives a ref to the value slot so you update it with one lookup. Enumeration order is not guaranteed; use OrderedDictionary<TKey,TValue> (.NET 9+) or SortedDictionary when order matters.
How to tell it (STAR)
**Situation:** our [metrics sidecar] used more CPU than the services it monitored. **Task:** I was asked to cut its cost across [300] nodes. **Action:** profiling showed dictionary lookups dominated, with every update hashing the key twice. I switched to GetValueRefOrAddDefault to update values through a ref in one lookup and cached common keys. **Result:** CPU dropped from [1.5] to [0.6] cores per node, which saved [about 270] cores across the fleet.
Last updated 30 Oct 2026.