Last Updated: May 17, 2026
A readonly struct is a value type the compiler refuses to mutate after construction. It's the struct counterpart to the immutable class pattern from the _Immutable Types_ lesson, with one extra payoff: the compiler can skip hidden defensive copies that plain structs trigger, which makes the code both safer and measurably faster. This lesson covers the readonly modifier on structs and on individual members, why structs especially want immutability, and where the performance wins come from.
The _Struct Basics_ lesson covered the core rule: structs are value types, so assignment copies the data. When you pass a struct to a method, the method gets its own copy. When you store a struct in a property and read it back, you get a fresh copy. Most of the time this is fine and predictable.
The trouble starts when a struct has methods that mutate its own fields. Take this plain (non-readonly) struct:
The Shift call looks like it should move the warehouse, and it compiles without complaint. But the output shows the location didn't change. What happened?
When you read warehouse.Location, the getter returns a copy of the struct. The compiler then invokes Shift on that copy, mutates the copy's fields, and throws the copy away. The warehouse's actual location is untouched. The method had visible side effects on a temporary nobody can ever see again.
This is one of the most confusing classes of bugs in C#: code that looks correct, compiles cleanly, and silently does nothing useful. The root cause is mutable methods on value types interacting with the copy-on-read rules.
readonly struct fixes this by making mutation impossible at the language level. If Coordinate were declared public readonly struct Coordinate, the Shift method above would fail to compile. You'd be forced to either return a new Coordinate from Shift or remove the method entirely. Either way, the warehouse bug can't happen.
The top path is the plain-struct bug above: mutation happens on a copy nobody keeps. The bottom path is the same code with readonly struct, which turns the silent failure into a compile error.
readonly struct ModifierThe readonly keyword in front of struct (added in C# 7.2) makes the entire struct immutable. The compiler enforces two rules at the declaration site:
readonly.init-only (no auto-property with a set accessor).Here's Coordinate rewritten as a readonly struct:
A few things to call out. The properties are get-only auto-properties, which the compiler allows inside a readonly struct. Shift no longer mutates; it returns a new Coordinate. The original is untouched, mirroring how WithXxx methods work on immutable classes. And the warehouse bug from the previous section is now structurally impossible: there's no way to write Shift so that it tries to change Latitude or Longitude, the compiler rejects any such attempt.
Try adding a setter or a mutating method to see what the compiler does:
CS8341 flags the auto-property setter ("Auto-implemented instance properties in readonly structs must be readonly"). CS1604 flags the field assignment ("Cannot assign to this because it is read-only"). The error codes might shift slightly across compiler versions, but the messages tell you exactly what's wrong: this is a readonly struct, you can't mutate it after construction.
The Money type from e-commerce is another natural fit. Two Money values with the same amount and currency are the same money, mutation has no meaning, and Money is small enough that copying is cheap:
That output comment is a joke for the prose, the real output is exactly what you'd expect from the two Console.WriteLine calls:
Add follows the wither pattern: take the inputs, compute the result, return a new instance. The originals are untouched. Because Money is a readonly struct, the compiler guarantees no method on the type can ever break that contract.
Cost: A readonly struct is still a value type, so it's stored inline (on the stack for locals, inline inside containing objects for fields). No heap allocation is involved when you create one. The cost story is mostly about copies, which we'll cover in the defensive-copy section below.
readonly on Individual Struct MembersC# 8 added a more granular tool: you can mark individual members of a plain (non-readonly) struct as readonly. The marker is a promise from the developer to the compiler: this member doesn't mutate this.
Why would you want that on a struct that isn't fully readonly? Two reasons. First, you might have a struct where most operations are pure but a few legitimately mutate state, and you want the compiler to verify which ones are pure. Second, even on a mostly-mutable struct, member-level readonly eliminates defensive copies at the call site (covered in the next section).
Here's a struct where some methods mutate and some don't:
The readonly on Current, IsEmpty, and ToString tells the compiler "these members do not change this". If any of them ever tried to write to a field, the compiler would reject it:
The readonly marker is contagious in a useful way: a readonly member can only call other readonly members on this. If Current tried to call Increment from inside its getter, the compiler would flag it because Increment doesn't have the readonly promise.
Cost: Marking a member readonly has no runtime cost. It's a compile-time annotation that affects how the compiler emits code at call sites, specifically by eliminating defensive copies (see the next section). Adding readonly to a member that doesn't mutate this is a free performance win in some scenarios.
On a fully readonly struct, every member is implicitly readonly, so you never need to write the modifier on individual members. The keyword on individual members is only relevant for plain structs.
This is where readonly struct and readonly members pay off at runtime, not just at compile time. To understand why, you have to know about defensive copies.
When the compiler can't guarantee that a method call won't mutate a value-type field, it emits a hidden copy before the call. The method runs on the copy, and the actual field is left untouched. This protects the program from accidentally mutating data it shouldn't, at the cost of an extra allocation-free but still real copy operation.
Consider a plain (non-readonly) struct stored in a readonly field of another type:
Every call to Location.DistanceToOrigin() inside DistanceFromOrigin triggers a defensive copy. The compiler doesn't know whether DistanceToOrigin is going to mutate Location. It can't risk that, because Location is a readonly field, so the contract says "this field never changes after the constructor". To preserve that contract, the compiler copies Location first, then calls DistanceToOrigin on the copy.
A copy of Coordinate is sixteen bytes (two doubles). One copy per call is fine. A million copies per second in a hot loop is a measurable cost.
There are two ways to eliminate the copy:
Coordinate as a readonly struct. The compiler then knows no method on Coordinate can mutate it, so it skips the defensive copy entirely.DistanceToOrigin as a readonly member. Same effect: the compiler knows this specific method doesn't mutate, so no copy is needed.Here's the same code with option 1:
Functionally identical. Performance-wise, the call inside DistanceFromOrigin no longer triggers a defensive copy. The method runs directly against the stored field.
The top branch is the plain-struct path with a defensive copy on every call. The bottom branch is the readonly struct path where the compiler has enough information to skip the copy. For small structs this is microseconds. For large structs (32-byte vectors, 64-byte matrix slices) called millions of times per second, the difference shows up in benchmarks.
The defensive copy also kicks in for any value-type expression the compiler considers immutable from the caller's perspective: readonly fields, properties of struct type (the getter returns a value-type rvalue, which is logically a temporary), and in parameters (covered in the next section).
in Parameters and Defensive CopiesThe same defensive-copy logic applies to in parameters. An in parameter passes a value type by reference but forbids the method from assigning to it. Conceptually, the method gets a read-only view of the caller's value:
in is useful for large structs because the alternative (passing by value) copies the entire struct on every call. For a 64-byte matrix passed to a method called a million times, that's 64 MB of unnecessary copying per second. in skips the copy at the call boundary.
But there's a catch. Inside the method, every member access on an in parameter follows the same defensive-copy rules as a readonly field. If Coordinate is a plain struct, calling a.DistanceToOrigin() inside the method emits a defensive copy first. The whole point of using in (avoiding copies) is undone by hidden copies on every method call.
A small benchmark sketch shows the effect:
Output (illustrative, varies by machine and runtime):
The exact numbers depend on the JIT, the runtime version, the struct's size, and what the method does. But the relative shape is consistent: the plain struct pays for a defensive copy on every member access through the in parameter, the readonly struct doesn't.
Cost: Use in parameters when you have a large struct (say, 16 bytes or more) and you're calling a method many times. Pair in with a readonly struct so the copy-elimination actually happens. in on a plain struct often makes performance worse, not better, because of the per-call defensive copies.
There's also ref readonly for return types, which has the same flavor: return a value-type field by reference, but forbid mutation. Same rule applies: pair it with a readonly struct to get the benefit.
Here's a real-world flavor of the defensive-copy gotcha:
The Mark20PercentOff method looks like it should drop the list price by 20 percent. It compiles cleanly. It runs without exceptions. And it does nothing.
Two things are going on. ListPrice is a get-only property of struct type, so reading it returns a temporary copy of Price. The compiler invokes ApplyDiscount on that copy. The copy's Amount is reduced, then the copy is discarded. The product's actual ListPrice is unchanged.
The fix is to declare Price as a readonly struct and rewrite ApplyDiscount to return a new Price. Once Price is readonly, the compiler refuses to let ApplyDiscount mutate fields, which forces you to write the correct, non-destructive version:
The fix has two parts working together. Price is now readonly, so the language forbids mutating methods on it. The author is forced to make WithDiscount return a new Price. And Product.Mark20PercentOff is now explicit about reassigning the property, which is the only correct way to change a value-type property.
This pattern, "silent mutation on a value-type copy", is why so many C# style guides recommend readonly struct as the default and reserve plain struct for the rare cases where mutation is genuinely the right design.
readonly structThe readonly modifier is purely about mutability. It doesn't change how equality works.
A readonly struct still uses the default value-type Equals and GetHashCode, which are implemented via reflection in the base ValueType.Equals. That's slow and allocates a boxed copy on every call. For most domain types, you'll want to override both:
This is a lot of boilerplate for value equality. The _Record Struct_ lesson covers record struct and readonly record struct, which generate all of this for you from a one-line declaration. If you find yourself writing the boilerplate above by hand, readonly record struct Money(decimal Amount, string CurrencyCode) collapses it to one line with the same behavior.
Cost: The default ValueType.Equals uses reflection and boxes the struct on each call. For structs used as dictionary keys or compared in tight loops, this is a measurable slowdown. Override Equals, GetHashCode, and IEquatable<T> explicitly, or use a readonly record struct to get the override generated automatically.
readonly structThe recommendation is straightforward: use `readonly struct` as the default for any new struct. Reach for plain struct only when you have a specific reason mutation is the right design (which is rare).
Use readonly struct when:
Money, Price, Coordinate, DateRange, TaxRate, DiscountCode.in parameters or stored in readonly fields and you want to avoid defensive copies.Use a plain struct when:
in parameter. This is unusual outside of low-level systems code.Span-adjacent APIs).Use member-level readonly when:
For the bulk of new code, the defaults that serve you well are: readonly record struct for value objects with equality, readonly struct when you need fields and custom logic without record syntax, plain struct only when you have a specific reason. The _ref Struct_ lesson covers the third orthogonal modifier, which adds stack-only allocation guarantees, and the _Record Struct_ lesson covers the equality story in detail.
readonly struct (C# 7.2) is a value type whose instance fields are all readonly and whose properties are all get-only or init-only. The compiler refuses to compile any method that assigns to a field.readonly) structs with mutating methods cause silent bugs when accessed through property getters, readonly fields, or in parameters, because the mutation happens on a throwaway copy.readonly modifier on individual struct members (C# 8) is a per-method promise that the member does not mutate this. Marking pure members readonly eliminates defensive copies at their call sites.readonly fields, property getters, in parameters. readonly struct and readonly members are how you tell the compiler the copy isn't needed.in parameters with readonly struct. in on a plain struct often loses to by-value passing because every method call inside the method triggers a per-call defensive copy.readonly is purely about mutability. To get value equality, override Equals, GetHashCode, and IEquatable<T> manually, or use a readonly record struct.readonly struct (or readonly record struct). Reach for plain struct only when in-place mutation is genuinely part of the design, which is rare outside of low-level performance code.