AlgoMaster Logo

Init-Only Properties

Medium Priority20 min readUpdated June 6, 2026

A property declared { get; init; } accepts writes during construction and refuses them afterward. This lesson goes one level deeper. It shows how the compiler enforces that rule, how init desugars in IL, how it interacts with with expressions on records, how it behaves under inheritance, and where it sits next to its newer cousin required.

How the Compiler Enforces "Construction Only"

Misusing init produces CS8852: "Init-only property or indexer ... can only be assigned in an object initializer, or on this or base in an instance constructor or an init accessor." The list inside that error message is the entire allow-list. Assignment from anywhere else is a compile-time error, not a runtime one.

Knowing exactly which sites count as "construction" is what makes this rule predictable. Five contexts can write to an init-only property:

  1. The class's own instance constructor body (assigning to this.Prop or just Prop).
  2. A derived class's constructor body, assigning to a base-class init-only property.
  3. An object initializer (new Order { Total = 99m }).
  4. The right-hand side of a with expression on a record.
  5. The body of another init accessor on the same object.

Anywhere else, including an instance method, an event handler, a property getter, or a static method that receives the object as a parameter, the assignment is rejected.

The constructor wrote to Id and Total, the object initializer overwrote Total. Both are legal sites. The commented Discount body and the commented assignment after construction are both compile errors with the same code, CS8852. Same error code, two different contexts.

The error is a Roslyn check, not a runtime check. There is no exception thrown at runtime if you somehow bypass the rule, the program won't compile in the first place. That distinction matters when you start thinking about reflection.

CS8852 is a compile-time gate, so it costs nothing at runtime. The generated property setter is identical in performance to a regular set accessor. You're paying with compiler enforcement, not CPU cycles.

What init Actually Compiles To

The reason init works at all without changing the underlying CLR is a small trick the C# compiler plays. At the IL level, an init accessor is just a normal property setter, named set_PropertyName with a void return and a single value parameter. There is no new "init" method kind. What the compiler does add is a modifier on the return type, a so-called modreq referencing the type System.Runtime.CompilerServices.IsExternalInit.

Here's roughly what the compiler emits for public int Quantity { get; init; }, written out as C# rather than IL:

The modreq(IsExternalInit) on the setter is the entire mechanism. A C# compiler that recognises this modreq treats the setter as init-only. A C# compiler that does not recognise it (pre-9 versions) cannot call the setter at all, because the language rule is "if you don't understand a modreq, refuse to bind to the member." That's exactly the behavior the language designers wanted: old compilers ignore the setter completely, new compilers gate it to construction sites.

IsExternalInit itself is just an empty marker class:

It lives in System.Runtime on .NET 5+. If you target .NET Standard 2.0 or .NET Framework, where the type is missing, you can declare it yourself anywhere in your project and the compiler picks it up. That's how libraries get init to work on older runtimes without language version downgrades.

The cyan node is the source you write. The orange nodes are the IL the compiler produces. The two leaf nodes show how compilers of different versions interpret the modreq. A new compiler enforces the construction-only rule, an older one acts as if the setter doesn't exist.

One consequence to know: because the setter is a real public method at the IL level, reflection can call it. typeof(Order).GetProperty("Total").SetValue(order, 999m) does not respect the modreq, so it succeeds at runtime even though the same assignment in source would fail to compile. Serializers (System.Text.Json, Dapper, Entity Framework) lean on this to populate immutable objects without a public constructor. The "immutability" of init is enforced by the compiler, not by the runtime.

Init Inside the Constructor and From Other init Accessors

The construction window is wider than just object initializers. Anywhere inside a constructor body, you can assign to an init-only property on this. The same is true inside another init accessor on the same object. This second case is what makes init-only properties chainable when you want one property to influence another.

The Subtotal init accessor wrote to Total through a separate init-only setter on the same object. That's legal because the assignment is on this and the writing accessor is itself an init. Outside that window, a method body, a static helper, an event handler, none of them can write Total.

Order matters in the initializer. The compiler emits the setters in source order, so Discount = 5m ran before Subtotal = 59.97m. By the time Subtotal's init accessor read Discount, that field already held the caller's value. Swap the lines in the initializer and Discount would still be 0 when Subtotal ran, producing a different Total.

This pattern (init accessors with side effects on sibling properties) is rare. Most init-only properties are pure storage. The capability exists, though, and it can be useful when one property is derived from another at construction time and you want to compute the derived value exactly once.

The init accessor normalised the input before storage. The setter ran during construction, did its work, and then locked itself. A regular set would have done the same normalisation but allowed callers to overwrite the value later. With init, the normalised form is the final form.

with Expressions: Why Records Need init

Records and init-only properties are joined at the hip. The with expression on a record (record1 with { Total = 99m }) produces a new record instance that copies every field from the original and overrides the named ones. The way the compiler implements that copy is the reason init exists at all.

A with expression compiles to roughly this sequence:

  1. Call the record's compiler-generated copy constructor, which produces a shallow clone.
  2. Treat the clone as a fresh object inside its construction window.
  3. Run the assignments listed in the with block against that clone using its init setters.
  4. Return the clone.

The clone is in its construction window during step 3, which means init accessors are writable there. If records used regular set accessors, with would still work, but the resulting record would be mutable forever. If records used get-only properties, with couldn't write at all and the feature would be useless. The init accessor is exactly the middle ground records needed.

Three records, three distinct instances. apple was never mutated. sale was a new clone with Price overridden. restocked was another clone derived from sale with Stock overridden. Each with produced a new object; the originals were untouched. That's the whole point of immutable data: every "change" creates a new value, leaving the old one stable.

The positional parameters in record Product(string Name, decimal Price) compile to init-only properties too. The compiler generates public string Name { get; init; } and public decimal Price { get; init; } from that declaration. You can confirm this by writing apple.Name = "Pear"; (CS8852).

The cyan node is the original record. The copy constructor produces a clone that briefly enters a fresh construction window, just long enough for the init setters in the with block to run. The result is the green node, a wholly separate object.

A class can have init-only properties without being a record, and a record can have regular set accessors if you really want to (you'd be defeating the point of records, but the compiler allows it). The with expression itself only works on records and record struct types, not on plain classes, because plain classes don't get a compiler-generated copy constructor. The Records lesson earlier in this section covers that machinery in more depth.

Init-Only vs Get-Only Constructor Properties

Init-only and get-only are not the same thing, even though they both produce objects that are read-only from the outside. The trade-offs are subtle enough that the choice matters in real code.

A get-only auto-property forces every value into the constructor. With ten optional fields, that means either ten constructors or one ten-parameter constructor with default arguments. Both ways age badly: ten constructors fan out into a maintenance burden, and a giant parameter list is brittle when callers rely on positional arguments.

An init-only auto-property keeps the parameter list short and lets the caller name what they're setting, which is far more readable at the call site:

The two flavors produce identical objects, but the init-only version reads better at the call site. You can also leave out fields you don't care about and rely on initializers, which is impossible with a positional constructor unless you build out a matching set of overloads.

Feature{ get; }{ get; init; }
Set from object initializerNoYes
Set from same-class constructorYesYes
Set from derived-class constructorYes (chained via :base())Yes (directly or via :base())
Set from with expression on a recordNoYes
Set from reflectionNo (backing field is readonly)Yes (regular setter under the hood)
Forces every value through a positional constructorYesNo
Friendly to deserializersOften requires a constructor matchWorks with member-by-member assignment

The "friendly to deserializers" row is worth pausing on. System.Text.Json, when deserializing into a type with init-only properties, can assign each property individually after calling the parameterless constructor (or a matching constructor for required fields). With a get-only property and no matching constructor, deserializers either need explicit attributes mapping JSON fields to constructor parameters or have to fall back to setting the underlying field through reflection. Init-only is the lower-friction shape.

There's one case where get-only still wins. If your class has an invariant that requires reading multiple fields together at construction time, the constructor is the only place where you know every value has been set. With init-only properties set through an object initializer, the constructor body runs before any of those fields receive their caller-supplied values, so any validation in the constructor sees defaults rather than the real inputs. For cross-field invariants, a positional constructor is still the cleanest place to enforce them.

Init and Inheritance

Init-only properties play normally with the class hierarchy. A derived class can:

  • Inherit an init-only property from the base.
  • Override a virtual init-only property and keep it init-only.
  • Override a virtual init accessor with a regular set, which the language allows (although it is almost always a design mistake).
  • Hide a base init-only property with a new init-only property using new.

A derived class assigning to a base init-only property from inside its own constructor is allowed. The base's init accessor is callable from there because the derived constructor is still inside the "construction window" of the same object.

DiscountedProduct.Price = price inside the constructor calls Product.set_Price, the base init setter. That's legal because we're still constructing the object. The object initializer then set Name after the constructor returned, also legal, the construction window stays open until the initializer finishes.

Overriding works the same way it does for regular properties. A virtual init-only property in the base can be overridden in the derived class:

The override changes both accessors. The init stores the raw input, the get applies an 8 percent tax on read. Callers see the same init shape from outside, the polymorphism is invisible to them.

One edge case to remember: the language permits an override to change the accessor kind from init to set (or vice versa), but doing so breaks the contract the base class was advertising. If Product.Price is init-only and TaxedProduct.Price is set-accessible, then TaxedProduct.Price = 50m works on a TaxedProduct reference but fails on a Product reference holding the same object. That kind of asymmetry confuses callers and almost always indicates a design problem. Stick to matching the base's accessor.

Virtual property dispatch is a single indirect method call, the same as any other virtual member. The JIT can devirtualize when it sees a sealed type or a known-final hierarchy, dropping the cost to a direct call. Init-only versus set-only makes no difference here.

Init-Only vs required: A Preview

C# 11 added the required modifier, which lives on top of init (or set) and tells the compiler the property must be assigned at the construction site. Without required, an init-only property is optional, the caller can omit it from the initializer and the property gets its default value. With required, the compiler refuses to compile any construction that doesn't supply a value.

required plugs the one gap init-only had compared to a positional constructor: the inability to force the caller to supply a value. Together, required plus init give you the readability of object initializers, the immutability of init, and the safety of "you must set this," with no positional constructor to maintain. For now, treat required as a modifier you can sprinkle on top of init when a property must not be left at its default.

Design Guidance: When to Use Init-Only

The default shape for a "data" class in modern C# is init-only properties on top of a class or record. The reasoning is short:

  • Init-only matches the way callers actually want to construct objects: name what you're setting, skip what you don't care about.
  • The resulting object is immutable, which removes a large class of bugs (concurrent mutation, defensive copies, accidental writes through aliased references).
  • It works cleanly with records and with expressions.
  • Deserializers can populate the object member by member without a matching positional constructor.
  • Combined with required, you get compile-time enforcement of "must be set."

There are still cases where you should use a regular set:

  • Working data that genuinely changes: a cart's Total and ItemCount while items are being added, a connection's IsOpen flag, a counter. The { get; private set; } pattern fits this case.
  • Mutable view models bound to UI controls: WPF, MAUI, Avalonia bindings often require two-way set accessors. Some frameworks special-case init, but many do not.
  • Properties whose value is genuinely a function of other state but cached: a stored backing field with a set that invalidates the cache, recomputing on next read.

The mental model is: start with init, downgrade to set only when the type's behavior demands it. Most data-shaped classes never need the downgrade.

Cart exposes Owner as init-only because it's identity-shaped (you don't change who owns the cart after creation). The Total and ItemCount track running state that's expected to change, so they use private set to keep the writes inside the class. Address is pure data, every property is init-only.

The split reads cleanly. Identity-shaped fields lock once. State-shaped fields move under the class's control. Internal-only writes use private setters. External code only sees the public surface, which is all reads.

Common Edge Cases

A few situations come up often enough to be worth calling out explicitly.

Init-only properties with backing fields are still init-only. The full-form property with an explicit field works the same way the auto-property does. Only the accessor declaration matters:

An init accessor can throw. Validation in an init body works exactly like validation in a set body. The throw happens at the construction site, before the object reference is handed back. The half-built object never escapes:

The field keyword used here is a C# 13 feature that lets accessors reference the compiler-generated backing field without declaring it. It works the same way inside an init accessor as inside a set accessor.

Init-only properties on structs work the same way as on classes. The construction window for a struct is its constructor or an object initializer. The record struct declaration produces init-only properties by default, just like record:

The readonly record struct form is the canonical shape for small value-shaped types. Both Amount and Currency compile to init-only properties, and with clones the value type and applies the override.

Reflection bypasses init-only. Worth repeating because it's a frequent source of confusion. Init-only is a compile-time rule, not a runtime one. SetValue through PropertyInfo does not check for the IsExternalInit modreq, so it succeeds at runtime. If you need a value to be truly immutable at runtime (against malicious code or buggy deserializers), use a readonly field assigned in the constructor and don't expose a property setter at all:

The SetValue call worked even though order.Id = 9999; in source code would refuse to compile. This is by design: serializers depend on it. It also means "init-only" should be read as "compile-time immutable for well-behaved callers," not "tamper-proof."

Quiz

Init-Only Properties Quiz

10 quizzes