AlgoMaster Logo

ref readonly Parameters

Low Priority11 min readUpdated June 6, 2026

C# 12 added ref readonly as a parameter modifier. It tells the compiler to pass a value by reference (no copying) while forbidding the method from modifying what the reference points to. The feature exists for one main reason: large readonly structs that you want to pass cheaply, with a clear signal at the call site that the caller is sharing a real variable, not a temporary.

The Problem It Solves

The ref and in modifiers have been around for years, but they each leave a gap.

  • ref passes by reference and lets the method write back. Good for output, wrong as a "don't copy this" signal.
  • in passes by readonly reference. The method can't mutate the value, and the caller doesn't have to write in at the call site. The convenience cuts both ways: callers can pass literals and expressions, and they often don't realize they're sharing storage.

Pass-by-value is the safe default, but it copies the entire struct on every call. For a small struct (8 or 16 bytes) that cost is invisible. For a struct holding pricing context, an order snapshot, or a transform matrix, the copy starts to show up in profilers.

ref readonly fills the middle slot. It promises no writes, like in, but it demands the caller pass a real variable and (optionally) annotate the call site, like ref. The intent is explicit on both ends.

This lesson assumes familiarity with ref, in, and out parameters and with value-type copying, and goes deeper on ref readonly specifically.

Syntax and Behavior

A ref readonly parameter is declared like this:

Three things are worth noticing.

First, the parameter declares ref readonly PricingContext ctx. Inside the method, ctx reads like any other variable but cannot appear on the left of an assignment, and you can't pass it to anything that wants ref or out.

Second, the call site uses in ctx. That's the recommended annotation for ref readonly parameters. You can also write ref ctx (with a warning) or omit the annotation entirely (also with a warning). The compiler accepts all three because ref readonly was designed to interoperate with existing in and ref call sites.

Third, the argument ctx must be an actual variable. You cannot pass a literal, a property value, or an arithmetic expression directly. That restriction is what distinguishes ref readonly from in.

PricingContext here is 32 bytes (four decimal fields). Passing by value copies 32 bytes per call. Passing by ref readonly passes an 8-byte pointer instead. In a loop of 10 million calls, that's 320 MB of avoided copying.

ref readonly vs in vs ref vs by-value

The four ways of passing a parameter sit on two axes: by reference or by value, and writeable or readonly.

ModifierPass modeCallee can write?Caller can pass rvalues?Call-site annotation
(none)By value (copy)N/A, it's a copyYesNone
inBy readonly referenceNoYes (compiler creates temp)Optional (in)
ref readonlyBy readonly referenceNoNo (warning if rvalue)Recommended (in)
refBy writeable referenceYesNo (hard error if rvalue)Required (ref)

Two rows look almost identical: in and ref readonly. The runtime treats them the same: both pass a pointer, neither permits writes inside the method. The difference is what they signal.

  • in is convenient. Pass any expression. The compiler will spin up a hidden temporary if it needs to.
  • ref readonly is explicit. The caller must already have the variable in hand, and the call site should ideally say in. The signature is documenting: "I want a shared reference to your data, not a copy I made up."

The practical rule: use in when the parameter is small and you want callers to forget about the modifier. Use ref readonly when the parameter is a large struct and the caller really should be aware they're sharing storage.

Why "ref readonly" When We Already Have "in"?

A fair question. The history matters here.

in was added in C# 7.2 with two design choices that became regretted later.

  • The call site doesn't have to say in. That makes calling convenient, but it hides the fact that the caller is sharing storage.
  • The compiler silently materializes a temporary when needed (for example, passing a literal). The argument quietly becomes a one-use copy.

Both choices were aimed at making in painless to adopt. The trade-off was that authors of performance-sensitive APIs lost a way to say "the caller really should have this thing already in a variable."

ref readonly puts that back. It signals intent at the signature level and prompts a warning if the call site is sloppy. APIs in .NET 8 itself (System.Numerics, System.Runtime.CompilerServices) started adopting ref readonly in places that used to use in, precisely for that intent signal.

Large Struct Performance

The reason ref readonly exists is performance for large readonly structs. Consider an OrderSnapshot that captures everything needed to recompute pricing and shipping.

OrderSnapshot is around 64 bytes (the exact size depends on layout and padding). Passing it by value copies 64 bytes per call.

Pass-by-value here copies 64 bytes per call. Pass by ref readonly passes an 8-byte pointer. Over 5 million calls, that's roughly 320 MB versus 40 MB of memory traffic, and the difference shows up clearly in the JITted code.

The savings come from two places. The first is the obvious one: smaller arguments mean fewer bytes moved between registers and the stack. The second is that the JIT can often hoist field reads when it knows the storage is readonly and shared, rather than re-reading from a stack-local copy. The exact win depends on the struct size, the call site, and what the method does, but the direction is consistent: for structs above 16 to 24 bytes, by-reference wins.

Defensive Copies

Here's where the design gets subtle. The CLR guarantees that a ref readonly reference cannot be used to write back to its storage. But what if the method calls an instance method on the struct, and that method could write to this?

For a struct, every non-readonly instance method could in principle mutate fields. The compiler can't know whether ctx.GetHashCode() (or ctx.CalculateSomething()) might modify state. To preserve the readonly guarantee, the compiler inserts a defensive copy: it copies the struct into a fresh local and calls the method on the local. The original storage is safe, but the optimization the caller wanted is gone.

There are two ways to dodge the defensive copy:

  1. Mark the struct itself readonly struct. Every instance method is then guaranteed not to mutate this, and the compiler skips the copy.
  2. Mark individual methods readonly on a non-readonly struct (C# 8+). The compiler skips the copy when calling those specific methods.

The first call is direct: no defensive copy. The second call works because CalculateDiscount is marked readonly. If you removed the readonly modifier from CalculateDiscount, the second call would still produce $10.00, but the compiler would silently copy ctx first. The output looks the same, the performance does not.

A defensive copy on a 64-byte struct undoes the whole point of using ref readonly. If you're going to pass by ref readonly, also make the struct itself a readonly struct. Otherwise, you're paying for the worst of both worlds.

Call-Site Rules and Compiler Warnings

The call site has three options, each with different compiler behavior. Assume void Process(ref readonly OrderSnapshot order).

Call siteArgument typeCompiler reaction
Process(in snapshot)Local or fieldClean, no warning
Process(ref snapshot)Local or fieldWarning CS9192: prefer in
Process(snapshot)Local or fieldWarning CS9192: prefer in
Process(in GetSnapshot())Method returnWarning CS9193: rvalue, temp materialized
Process(GetSnapshot())Method returnWarning CS9193

The warnings are all opt-in for migration. Code that compiled before still compiles. New code is nudged toward writing in at the call site and toward passing real variables.

The diagram lays out what the compiler does for each combination. An rvalue still works (the language doesn't break it) but generates a warning loud enough that the author has to acknowledge it. An lvalue with the right annotation is the happy path.

Migrating from in to ref readonly

If you maintain an API that uses in and want stronger documentation of intent, you can switch to ref readonly without breaking callers. Callers that already write in keep working with no warning. Callers that pass an rvalue start getting a warning that tells them to store the value in a variable first.

Both versions accept in quote cleanly. The difference shows up when a caller writes FormatAfter(new ShippingQuote { ... }) directly: the in version accepts it silently, the ref readonly version warns. That warning is the entire point of the migration.

The migration direction is one-way in practice. Going from ref readonly back to in is also source-compatible, but you lose the intent signal.

Restrictions and Pitfalls

ref readonly plays by the same scoping rules as other ref-like parameters, plus a few specific restrictions.

  • No `params`. You can't write params ref readonly int[] values. The params modifier creates a fresh array per call, which has no caller variable to reference.
  • No async methods. A ref readonly parameter can't appear in an async method, the same restriction that applies to ref and in. References can't be captured across an await because the stack frame may be gone.
  • No iterator methods. Methods with yield return also can't take ref readonly parameters, for the same reason.
  • Lambda capture is limited. A lambda inside the method cannot capture a ref readonly parameter directly. You'd have to copy the value into a regular local first, which defeats the purpose.
  • Cannot be `null`. ref readonly parameters are managed references, not pointers. They always refer to a real location. There's no null to check for.

The comment shows what doesn't work. The rest is normal.

When to Use ref readonly

In day-to-day C#, by-value is still the right default. Use ref readonly when all of these are true.

  1. The parameter is a struct, ideally a readonly struct.
  2. The struct is large enough that copying matters (rule of thumb: above 16 bytes).
  3. You want callers to be conscious that they're sharing storage. Maybe the API is in a hot path and you want a warning if someone passes a temporary.
  4. The method only reads from the parameter and never needs to mutate.

If any of those is false, prefer something simpler. By-value for small structs. By-reference (ref) when the method should write back. in when you don't care whether the caller uses a variable or an expression.

The .NET BCL itself uses ref readonly sparingly, mostly in System.Numerics (Matrix4x4, Vector3 operations) and similar performance-critical, large-struct APIs. Application code rarely needs it. Library code aimed at game engines, graphics, physics, or high-throughput trading systems is where it pays off.

Quiz

ref readonly Parameters Quiz

10 quizzes