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 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.
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.
The four ways of passing a parameter sit on two axes: by reference or by value, and writeable or readonly.
| Modifier | Pass mode | Callee can write? | Caller can pass rvalues? | Call-site annotation |
|---|---|---|---|---|
| (none) | By value (copy) | N/A, it's a copy | Yes | None |
in | By readonly reference | No | Yes (compiler creates temp) | Optional (in) |
ref readonly | By readonly reference | No | No (warning if rvalue) | Recommended (in) |
ref | By writeable reference | Yes | No (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.
A fair question. The history matters here.
in was added in C# 7.2 with two design choices that became regretted later.
in. That makes calling convenient, but it hides the fact that the caller is sharing storage.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.
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.
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:
readonly struct. Every instance method is then guaranteed not to mutate this, and the compiler skips the copy.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.
The call site has three options, each with different compiler behavior. Assume void Process(ref readonly OrderSnapshot order).
| Call site | Argument type | Compiler reaction |
|---|---|---|
Process(in snapshot) | Local or field | Clean, no warning |
Process(ref snapshot) | Local or field | Warning CS9192: prefer in |
Process(snapshot) | Local or field | Warning CS9192: prefer in |
Process(in GetSnapshot()) | Method return | Warning CS9193: rvalue, temp materialized |
Process(GetSnapshot()) | Method return | Warning 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.
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.
ref readonly plays by the same scoping rules as other ref-like parameters, plus a few specific restrictions.
params ref readonly int[] values. The params modifier creates a fresh array per call, which has no caller variable to reference.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.yield return also can't take ref readonly parameters, for the same reason.ref readonly parameter directly. You'd have to copy the value into a regular local first, which defeats the purpose.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.
In day-to-day C#, by-value is still the right default. Use ref readonly when all of these are true.
struct, ideally a readonly struct.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.
10 quizzes