readonly across fields, structs, members and refs, const versus static readonly, and immutability limits.
readonly always means "this storage cannot be reassigned after initialisation", but the scope differs. A readonly field can be set only in its initialiser or a constructor (or an init accessor). const is a compile-time literal copied into every caller, so changing it in a library does nothing until callers recompile; static readonly is read at run time, and the tiered JIT treats it as a constant once initialised, so it is just as fast for primitives. A readonly struct has only readonly fields, and readonly members (C# 8) promise not to mutate this, which removes defensive copies. ref readonly returns give a reference callers cannot write through. None of these make referenced objects immutable: a readonly List<T> can still be added to.
readonly means "can't be reassigned after initialisation", but what is read-only depends on where it appears: a field, a struct, a struct member, or a reference returned by ref readonly. The common thread is that readonly protects a storage location, not the object graph behind it. I'd go through each place, static readonly vs const (and the versioning trap), how the JIT treats static readonly, and why readonly isn't immutability.
readonly fieldspublic sealed class OrderService(IOrderRepository repo)
{
private readonly IOrderRepository _repo = repo; // assign once
private readonly List<string> _audit = []; // reference can't change...
public void Note(string s) => _audit.Add(s); // ...but the list's contents can
}
init accessors.FieldInfo.SetValue) can still change it (and static readonly writes via reflection are blocked/undefined in modern runtimes after initialisation) — it's a language guarantee, not a security boundary.static readonly vs const (the versioning trap)const |
static readonly |
|
|---|---|---|
| Evaluated | Compile time | Run time (type initialisation) |
| Types | Primitives, string, enums, null |
Any type |
| Stored in callers | Value copied into caller IL | Callers read the field |
| Change in library v2 | Old callers keep old value until recompiled | Picked up by old callers |
Usable in attributes, case labels, default parameters |
Yes | No |
The versioning trap: public const int MaxPageSize = 50; in a NuGet package — changing it to 100 does nothing for consumers compiled against v1. Use const for true constants (BitsPerByte) or non-public values; use static readonly (or a property) for public values that may change.
static readonlyWith tiered compilation, hot methods are recompiled at tier 1 after the type's static constructor has run. The JIT then knows the actual value of static readonly fields and treats them as constants:
private static readonly bool s_verbose = Environment.GetEnvironmentVariable("VERBOSE") == "1";
if (s_verbose) Log(...); // at tier 1, the whole branch can be eliminated when false
This also applies to static readonly object references (devirtualising calls on them) and primitive values (constant folding). So static readonly usually performs like const in steady state, while staying configurable.
readonly structpublic readonly struct Money(decimal amount, string currency)
{
public decimal Amount { get; } = amount;
public string Currency { get; } = currency;
}
readonly; auto-properties must be get-only or init.this, so it skips defensive copies when the struct is accessed via in parameters, readonly fields or ref readonly refs.readonly members (C# 8)public struct Counter
{
public int Value;
public readonly int Doubled() => Value * 2; // promises not to mutate
public void Increment() => Value++;
}
For mutable structs, marking non-mutating members readonly avoids defensive copies when called on read-only locations. The compiler rejects mutations inside readonly members.
ref readonly returns (and locals)private Matrix4x4 _transform;
public ref readonly Matrix4x4 Transform => ref _transform; // no 64-byte copy; caller can't modify
ref readonly var t = ref obj.Transform;
Returns a reference to storage without allowing writes through it — cheap access to large structs. If the struct isn't readonly, member calls through t may still cause defensive copies.
readonly doesn't make an object immutablereadonly field holding a reference type prevents reassigning the reference, but the referenced object (a List<T>, a mutable entity, an array) can still change.readonly arrays: private static readonly int[] Primes = {2, 3, 5}; — anyone with access can do Primes[0] = 99.readonly struct containing a reference to a mutable object is only shallowly immutable.readonly field as a public IReadOnlyList<T> view still allows casting back.For real immutability: immutable types all the way down (records with init, ImmutableArray<T>, FrozenSet<T>, ReadOnlySpan<T> static properties for constant data, string).
A rules service exposed public static readonly string[] AllowedCountries = { "AU", "NZ" }; A feature for a pilot in Singapore did AllowedCountries[1] = "SG" at start-up "temporarily" — and New Zealand customers were rejected for a day. Changing it to public static readonly FrozenSet<string> AllowedCountries = new[] { "AU", "NZ" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); made mutation impossible and lookups faster; the pilot got proper configuration.
The interviewer checks precise understanding of readonly's forms and their limits.
Green flags
Red flags
readonly field → initonly flag. The JIT/verifier allow stores only in constructors (or .cctor for static).const → literal field with a Constant metadata value; no storage read at run time.readonly struct → [IsReadOnly] attribute on the type; readonly members → [IsReadOnly] on methods.ref readonly return → modreq(InAttribute)/[IsReadOnly] on the return.static readonly and the runtimeSince .NET Core 3.0, the runtime treats static readonly fields as truly read-only after the type is initialised: writes via reflection throw FieldAccessException ("cannot set initonly static field after type is initialized"). This guarantee is what lets the JIT fold their values.
private readonly MutableStruct _s;
_s.Mutate(); // operates on a COPY - original unchanged (CS1648 for direct field assignment; method calls compile)
This is a classic bug source: methods called on readonly struct fields silently don't change them.
readonly and threadingreadonly fields assigned in constructors are visible to other threads once the object is safely published (e.g. via DI or a volatile write). But mutable objects referenced by readonly fields still need synchronisation. Immutability, not readonly, gives thread safety.
| Need | Use |
|---|---|
| Universal constant | const (internal/private, or truly fixed) |
| Public configurable value | static readonly or property |
| Dependency stored once | private readonly field |
| Immutable small value type | readonly struct / readonly record struct |
| Large struct exposed cheaply | ref readonly return |
| Truly immutable collection | ImmutableArray<T>, FrozenSet<T>, ReadOnlySpan<T> static property |
public const in a library, rebuild only the library, and run an old consumer.readonly field of a mutable struct and print the result.static readonly field via reflection after initialisation.static readonly int[] from another class.using System.Collections.Frozen;
using System.Collections.Immutable;
public static class Limits
{
public const double GstRate = 0.10; // safe as const only if it truly never changes for callers
public static readonly int MaxBasketItems = // read at run time; JIT treats it as a constant at tier 1
int.TryParse(Environment.GetEnvironmentVariable("MAX_BASKET"), out var n) ? n : 50;
public static readonly FrozenSet<string> States =
new[] { "NSW", "VIC", "QLD", "WA", "SA", "TAS", "ACT", "NT" }.ToFrozenSet();
}
public readonly struct Money(decimal amount, string currency) // readonly struct: no defensive copies
{
public decimal Amount { get; } = amount;
public string Currency { get; } = currency;
public Money WithGst() => new(Amount * (1 + (decimal)Limits.GstRate), Currency);
}
public struct Counter
{
private int _value;
public void Increment() => _value++;
public readonly int Value => _value; // readonly member: safe to call on readonly instances
}
public sealed class Basket
{
private readonly List<string> _items = []; // field cannot be reassigned...
private readonly Counter _changes; // readonly struct field
public void Add(string sku)
{
if (_items.Count >= Limits.MaxBasketItems) throw new InvalidOperationException("Basket full");
_items.Add(sku); // ...but the list itself can still change
// _changes.Increment(); // would mutate a defensive copy, not the field
}
public ImmutableArray<string> Snapshot() => [.. _items]; // truly immutable copy for callers
public int Changes => _changes.Value;
}
public static class Program
{
public static void Main()
{
var basket = new Basket();
basket.Add("SKU-1");
Console.WriteLine($"{basket.Snapshot().Length} item(s), max {Limits.MaxBasketItems}");
Console.WriteLine(new Money(100m, "AUD").WithGst().Amount); // 110.0
Console.WriteLine(Limits.States.Contains("WA"));
}
}
readonly means immutableWhat happens: readonly List<T> fields considered safe to share.
Why it is wrong: contents can still change.
Do instead: immutable or frozen collections.
const for values that changeWhat happens: API versions, limits, URLs as public const.
Why it is wrong: consumers keep old values until recompiled.
Do instead: static readonly or properties.
static readonly arrays as constant tablesWhat happens: public static readonly int[] Table = {...}.
Why it is wrong: elements can be modified by anyone.
Do instead: ReadOnlySpan<T> static properties or ImmutableArray<T>.
What happens: calling mutating methods on readonly struct fields.
Why it is wrong: changes apply to defensive copies.
Do instead: readonly struct or non-readonly fields with clear mutation.
static readonly for performanceWhat happens: using const everywhere because "static readonly is slower".
Why it is wrong: tier-1 JIT folds static readonly values.
Do instead: choose by versioning semantics.
inWhat happens: getters and methods without readonly modifiers.
Why it is wrong: hidden copies.
Do instead: mark members/structs readonly.
readonly for securityWhat happens: assuming reflection can't change readonly instance fields. Why it is wrong: reflection can modify instance readonly fields. Do instead: treat it as a design constraint.
ref instead of ref readonlyWhat happens: returning mutable refs to internal state.
Why it is wrong: callers can bypass invariants.
Do instead: ref readonly returns.
readonly is about intent and compiler guarantees, and each form protects a different thing. Senior engineers use the strongest form that fits:
| Want to protect | Use |
|---|---|
| Dependency or configuration reference | readonly field |
| Value that may change between releases | static readonly |
| Truly permanent literal | const |
| Struct state | readonly struct / readonly members |
| Collection contents | immutable or frozen collection |
I would mention the binary compatibility angle explicitly, because the const versioning trap causes real incidents in multi-repo organisations, and the JIT angle, because it explains why static readonly feature flags read at start-up are effectively free.
Immutable collections (System.Collections.Immutable) never change after creation; "modifying" methods such as Add return a new collection and leave the old one untouched, so they are safe to share across threads without locks. ImmutableArray<T> is a struct around a plain array: reads are as fast as an array, but every Add copies the whole thing (O(n)). ImmutableList<T> and ImmutableDictionary<TKey,TValue> are balanced trees that share structure: an Add is O(log n) and reuses most of the old nodes, but reads are slower. Use builders (ToBuilder() ... ToImmutable()) for many changes. A read-only view like ReadOnlyCollection<T> or IReadOnlyList<T> only stops you changing it; the underlying list can still change. FrozenDictionary/FrozenSet (.NET 8+) are immutable too but optimised purely for fast reads with no "modified copy" operations.
An in parameter (C# 7.2) passes an argument by readonly reference: no copy of the struct, and the method cannot assign to it. It helps for large structs (roughly over 16 bytes) on hot paths. The trap is defensive copies: if the struct is not readonly, calling a non-readonly method or property on an in parameter forces the compiler to copy it first, because the call might mutate it, which can make in slower than by-value. Mark structs readonly (or individual members readonly, C# 8) to avoid this. in is optional at the call site and accepts rvalues (the compiler creates a temporary). ref readonly parameters (C# 12) express "I need a real reference to a variable": callers should pass an lvalue with ref or in, and passing a temporary produces a warning. For small structs and all classes, pass by value.
A record struct (C# 10) is a value type with record features: generated Equals, GetHashCode, ==, ToString and, for positional ones, Deconstruct. Unlike a record class, its positional properties are mutable (get; set;) by default; readonly record struct makes them init and the struct readonly, which is what you usually want. Its generated equality compares fields directly, so it avoids the reflection and boxing that a plain struct's inherited ValueType.Equals can fall back to. It has no EqualityContract and no inheritance. Watch the default value: default(OrderId) bypasses constructors and validation. Use it for small, immutable identifiers and values (ids, coordinates, money) used in hot paths or as dictionary keys.
How to tell it (STAR)
**Situation:** when our [payments partner] retired an API version, some of our services kept calling it although we had shipped the update. **Task:** I led the incident and needed to find why only some services failed. **Action:** I decompiled a failing binary and found the old version was compiled in from a public const. I rebuilt and redeployed those services, changed the field to static readonly, and added a rule against public consts in shared packages. **Result:** service was restored in [40 minutes] and we have not had a stale-constant issue since.
Last updated 30 Oct 2026.