1. Introduction
Auto-properties were a welcome addition back in C# 3.0. Instead of declaring a private backing field, writing a getter that returns it, and writing a setter that assigns to it, you could collapse all of that into a single line. Clean, fast, done.
But that convenience came with a hard ceiling. The moment you needed anything beyond a straight get/set — a range check in the setter, a default value computed at first access, a bit of transformation before storing a value — the compiler-generated backing field became completely inaccessible to you. Your only option was to abandon the auto-property entirely: declare an explicit _value field, re-wire the getter and setter by hand, and end up with four to eight lines of boilerplate where one used to live. For something as common as input validation, that felt like a disproportionate tax.
C# 13 addresses this with the field keyword and the concept of semi-auto properties. The idea is straightforward: let the compiler still generate the backing field, but give you access to it inside the property body through the contextual keyword field. You keep the convenience of not naming the field yourself, and you get the flexibility to add logic around it.
It’s a small addition to the language. The impact on day-to-day code is anything but.
2. A Quick Refresher: Auto-Properties vs. Full Properties
Before diving into field, it’s worth grounding ourselves in the two patterns it sits between, because semi-auto properties only make sense as a middle ground once you feel the friction at both ends.
Auto-properties are the ones you write without thinking:
public class Product
{
public string Name { get; set; }
public decimal Price { get; set; }
}Code language: C# (cs)The compiler generates a private backing field for each one behind the scenes. You never see it, you never touch it, and that’s the whole point. For simple data-carrier types, this is exactly what you want.
The trouble starts when requirements creep in. Say Price can’t be negative. Now you need to intercept the setter. And the moment you do that, auto-property syntax is no longer on the table:
public class Product
{
private decimal _price;
public string Name { get; set; }
public decimal Price
{
get => _price;
set
{
if (value < 0)
throw new ArgumentOutOfRangeException(nameof(value), "Price cannot be negative.");
_price = value;
}
}
}Code language: C# (cs)That’s a significant jump in verbosity for what is, conceptually, a very minor change in behavior. You’ve gone from one line to nine, and you’ve had to introduce a private field with a name you now have to maintain, keep consistent with any naming conventions your team follows, and make sure never collides with anything else. The logic itself — the actual interesting part — is just two lines buried in the middle.
This is the gap that exists between auto-properties and full properties, and it’s a gap C# developers cross dozens of times in any reasonably sized codebase. Renaming a field during a refactor, keeping _price and Price in sync, making sure you didn’t accidentally reference the field directly somewhere and bypass the validation — none of it is hard, but all of it is noise.
Semi-auto properties live exactly in this space. Same compiled output, far less ceremony.
3. What Are Semi-Auto Properties?
The name “semi-auto property” isn’t an official C# specification term — it’s the shorthand the community and the C# team landed on to describe what this feature actually is: a property where the compiler still generates the backing field, but you write at least one accessor body yourself.
The mechanism is simple. Inside any property accessor, you can now use the contextual keyword field to refer to the compiler-synthesized backing field for that property. That’s it. No declaration, no naming, no private decimal _price sitting above the property. The field exists because the compiler creates it, and field is how you reach it.
Here’s the Price example from the previous section, rewritten as a semi-auto property:
public class Product
{
public string Name { get; set; }
public decimal Price
{
get => field;
set
{
if (value < 0)
throw new ArgumentOutOfRangeException(nameof(value), "Price cannot be negative.");
field = value;
}
}
}Code language: C# (cs)Nine lines down to seven, but more importantly, the backing field is gone. There’s nothing to name, nothing to keep in sync, and no risk of accidentally bypassing the property logic by referencing the field directly elsewhere in the class. The field keyword is only accessible inside the property’s own accessors — it’s completely invisible to the rest of the type.
It helps to think about the three property patterns as a clear spectrum:
- Auto-property: compiler writes both accessors and the backing field. You control nothing.
- Semi-auto property: compiler writes the backing field. You write one or both accessors, using
fieldto interact with it. - Full property: you write everything — the backing field, both accessors, all of it.
Semi-auto properties don’t replace either end of that spectrum. A plain { get; set; } with no extra logic should stay an auto-property. A property that needs two private fields, or that computes its value entirely from other properties with no storage at all, still warrants a full implementation. Semi-auto properties are specifically the answer to the case in the middle: you need a stored value, and you need a little logic around it.
One thing worth noting early: field is a contextual keyword, not a reserved one. That distinction matters, and we’ll get into the specifics in the next section.
4. The field Contextual Keyword — Syntax and Semantics
Let’s get precise about how field actually works, because there are a handful of rules and behaviors here that will save you from confusion later — especially around nullability, attribute targeting, and the contextual vs. reserved keyword distinction.
Contextual, Not Reserved
field is a contextual keyword, which means it only carries special meaning inside a property accessor body. Everywhere else in your code, field is a perfectly valid identifier. You can have a local variable named field, a parameter named field, a method named field — the compiler figures out from context which one you mean.
This was a deliberate language design decision. Making field a fully reserved keyword would have been a breaking change for any codebase that already used field as an identifier, which is not an uncommon thing in serialization code, reflection utilities, and ORM-related infrastructure. By keeping it contextual, the C# team avoided breaking existing code entirely.
That said, this creates one genuine footgun, which we’ll cover in detail in the gotchas section. For now, just keep in mind: if you have a parameter or local variable named field inside an accessor, it will shadow the keyword. The compiler will warn you, but it won’t stop you.
Where field Is Valid
The field keyword is valid inside the body of any of the following accessors:
getsetinit
It is not valid anywhere else in the class body. You can’t reference it from a constructor, a method, another property, or an expression outside of these accessor bodies. This is by design — the whole point is that the backing field is encapsulated within the property itself.
You also don’t need to use field in both accessors. These are all valid semi-auto properties:
// Only the setter uses field; getter has custom logic
public string Slug
{
get => field.ToLowerInvariant().Replace(' ', '-');
set => field = value.Trim();
}
// Only the getter uses field; setter is auto-generated
public DateTime CreatedAt
{
get => field;
set; // compiler handles this, field is assigned directly
}
// Only the setter has logic; getter is auto-generated
public string Username
{
get;
set => field = value?.Trim() ?? throw new ArgumentNullException(nameof(value));
}Code language: C# (cs)That last pattern is particularly useful — you can leave the getter as a simple auto-accessor and only write the setter when that’s the only side that needs logic, without having to write get => field; explicitly.
Type Inference
The type of field is inferred from the property’s declared type. There’s no ambiguity here and nothing to configure. If the property is declared as decimal Price, then field is a decimal. If the property is List<string> Tags, then field is a List<string>. You’re always working with the same type as the property itself.
Nullability
Nullability follows the property’s declared type as well, and this is where things get slightly nuanced. Consider a nullable reference type scenario:
public string? DisplayName
{
get => field ?? "Anonymous";
set => field = value;
}Code language: C# (cs)Here field is string?, matching the property type. The getter returns a non-nullable string by coalescing with a default, but the stored value can genuinely be null. The compiler understands this and won’t raise a nullability warning on the getter’s return path.
Where it gets trickier is when the property type is non-nullable but you want lazy initialization — initializing field on first access rather than at construction time:
public string Description
{
get => field ??= LoadDescription();
}Code language: C# (cs)The compiler will warn here because field is string (non-nullable) yet it’s being treated as potentially null through the ??= operator. You’d need to declare the property as string? and handle the nullability at the call site, or suppress the warning explicitly if you’re confident about the initialization path. This is one of the few places where the feature’s convenience has a minor rough edge.
Initializers
You can provide a property initializer the same way you would with an auto-property, and field will be pre-populated with that value:
public int RetryCount
{
get => field;
set => field = Math.Clamp(value, 0, 10);
} = 3;Code language: C# (cs)The = 3 at the end initializes the backing field to 3 before any constructor logic runs, exactly as it would with a plain auto-property. The setter’s clamping logic does not run during initialization — the value is written directly to field — so if you need invariants enforced even during initialization, you’ll want to handle that in the constructor instead.
LangVersion Requirement
Semi-auto properties and the field keyword require LangVersion set to preview in C# 13 during the preview period, and will require 13 or later once the feature ships in a stable release. If you’re targeting an older language version, field is simply treated as a regular identifier, and the compiler won’t synthesize a backing field for it. This can lead to confusing errors if you accidentally use field in a codebase with a lower LangVersion — worth checking your project file if something seems off.
5. Step-by-Step: Migrating Common Patterns to Semi-Auto Properties
This is where the feature earns its keep. Let’s walk through five patterns you’ve almost certainly written before — each one a case where an auto-property wasn’t enough and a full backing field felt like overkill. For each pattern, we’ll look at the old approach and then the semi-auto equivalent, with notes on what changed and why.
Pattern 1: Validation in Setters
This is the canonical use case, and the one the C# team almost certainly had front of mind when designing the feature.
Before:
public class Order
{
private int _quantity;
public int Quantity
{
get => _quantity;
set
{
if (value <= 0)
throw new ArgumentOutOfRangeException(nameof(value), "Quantity must be greater than zero.");
_quantity = value;
}
}
}Code language: C# (cs)After:
public class Order
{
public int Quantity
{
get;
set
{
if (value <= 0)
throw new ArgumentOutOfRangeException(nameof(value), "Quantity must be greater than zero.");
field = value;
}
}
}Code language: C# (cs)The private field is gone. The getter is now an auto-accessor — you don’t even need to write get => field; explicitly. The setter retains all its validation logic and writes to field instead of _quantity. The compiled output is identical to the before version; the difference is entirely in how much you had to write and maintain.
If you have multiple validated properties on the same class, the cumulative reduction in boilerplate is substantial. A class with five validated properties previously meant five private fields declared at the top, five property declarations below them, and the constant discipline of making sure nothing in the class bypassed a property by writing to the field directly. With semi-auto properties, that entire category of concern disappears.
Pattern 2: Lazy Initialization in Getters
Lazy initialization — computing or loading a value only when it’s first requested and caching it for subsequent accesses — is another pattern that’s simple in concept but verbose to implement with a full backing field.
Before:
public class ReportGenerator
{
private List<string>? _sections;
public List<string> Sections
{
get => _sections ??= LoadSections();
}
private List<string> LoadSections() { /* ... */ }
}Code language: C# (cs)After:
public class ReportGenerator
{
public List<string>? Sections
{
get => field ??= LoadSections();
}
private List<string> LoadSections() { /* ... */ }
}Code language: C# (cs)Notice that the property type has to be List<string>? here rather than List<string>, because field starts as null and the nullability analyzer needs to know that’s intentional. This is the nullability edge case mentioned in the previous section. Whether that nullable annotation leaks into your public API depends on how comfortable you are with suppressing the warning or adjusting call sites — it’s a minor trade-off worth being aware of.
That said, for internal properties, private properties, or cases where nullable reference types aren’t enabled, this pattern is a clean win with zero downsides.
Pattern 3: Trimming and Normalizing Input
String properties frequently need light sanitization on the way in — trimming whitespace, normalizing casing, collapsing empty strings to null. This is almost never worth a full backing field, but it’s always been just out of reach for auto-properties.
Before:
public class UserProfile
{
private string? _displayName;
public string? DisplayName
{
get => _displayName;
set => _displayName = value?.Trim();
}
}Code language: C# (cs)After:
public class UserProfile
{
public string? DisplayName
{
get;
set => field = value?.Trim();
}
}Code language: C# (cs)Short, obvious, and self-contained. The getter needs no custom logic so it stays as an auto-accessor. The setter does its one job. Anyone reading this class knows exactly what DisplayName does with assigned values without needing to scroll up to find a backing field declaration.
This pattern scales nicely. You might have a UserProfile with six string properties that all need trimming. Previously that meant six backing fields. Now it’s six properties with a one-line setter each and nothing else.
Pattern 4: Computed Caching
Sometimes a property’s value is expensive to compute — it might involve LINQ aggregation, string building, or any other non-trivial operation — but the inputs don’t change often enough to justify recomputing on every access. The classic solution is a nullable backing field used as a cache, cleared whenever the underlying data changes.
Before:
public class Invoice
{
private decimal? _total;
private List<LineItem> _lineItems = new();
public List<LineItem> LineItems
{
get => _lineItems;
set
{
_lineItems = value;
_total = null; // invalidate cache
}
}
public decimal Total
{
get => _total ??= _lineItems.Sum(x => x.Amount);
}
}Code language: C# (cs)After:
public class Invoice
{
public List<LineItem> LineItems
{
get;
set
{
field = value;
Total = null; // invalidate cache
}
} = new();
public decimal? Total
{
get => field ??= LineItems.Sum(x => x.Amount);
}
}Code language: C# (cs)Both LineItems and Total are now semi-auto properties. LineItems uses an initializer (= new()) to set the default value, and its setter invalidates the Total cache by setting it to null directly — which is valid because Total is declared as decimal?. The cache-and-compute logic in Total‘s getter is unchanged in behavior but no longer requires a separately declared _total field.
One thing to note: setting Total = null from within LineItems‘ setter assigns directly to Total‘s backing field only if Total has no setter of its own with custom logic. Since Total has only a getter here, the assignment Total = null won’t compile — you’d still need either a setter on Total or a separate backing field for this specific invalidation pattern. This is a case where semi-auto properties get you most of the way but a full backing field still makes sense for the cache field specifically. It’s a good example of the feature not being a universal replacement.
Pattern 5: Init-Only Properties with Transformation
Since C# 9, init accessors have allowed properties to be set during object initialization but made immutable afterward. Semi-auto properties work naturally with init, and the combination is particularly useful when you want to transform or validate a value at construction time.
Before:
public class Ticket
{
private string _reference;
public string Reference
{
get => _reference;
init
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Reference cannot be blank.", nameof(value));
_reference = value.ToUpperInvariant();
}
}
}Code language: C# (cs)After:
public class Ticket
{
public string Reference
{
get;
init
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Reference cannot be blank.", nameof(value));
field = value.ToUpperInvariant();
}
}
}Code language: C# (cs)Clean and immutable. The init accessor runs once during object initialization — via a constructor, an object initializer, or a with expression on a record — validates the input, normalizes it to uppercase, and writes it to field. After that, the backing field is frozen and Reference becomes effectively read-only for the lifetime of the object.
Combine this with the required modifier introduced in C# 11 and you have a tight, expressive pattern for value objects and domain entities:
public class Ticket
{
public required string Reference
{
get;
init
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Reference cannot be blank.", nameof(value));
field = value.ToUpperInvariant();
}
}
}Code language: C# (cs)Now Reference must be set during initialization, will be validated and normalized when it is, and can never be changed afterward. That’s a lot of correctness guarantees for very little code.
6. Using field with get-Only and init Accessors
Read-only properties are one of the more underappreciated tools in C#. They communicate intent clearly — this value is set once and doesn’t change — and they make types easier to reason about, especially in concurrent or functional-style code. Semi-auto properties work well in this space, but there are a few behavioral specifics worth understanding before you rely on them.
Get-Only Semi-Auto Properties
A get-only auto-property looks like this:
public string Id { get; }Code language: C# (cs)The compiler generates a read-only backing field for it, and the only place that field can be assigned is in the constructor or via a field initializer. That rule carries over to get-only semi-auto properties:
public string Id
{
get => field.ToUpperInvariant();
}Code language: C# (cs)This compiles. The backing field for Id is still read-only — it can only be assigned in the constructor or via an initializer — and the getter applies a transformation on the way out. You’re not storing the uppercased value; you’re storing whatever was assigned in the constructor and uppercasing it on every read. Whether that’s what you want depends on the situation, but it’s a valid and useful pattern for lightweight formatting that doesn’t warrant caching.
Here’s what constructor assignment looks like with a get-only semi-auto property:
public class Event
{
public string Name
{
get => field.Trim();
}
public Event(string name)
{
Name = name; // assigns directly to the backing field
}
}Code language: C# (cs)Notice that the constructor assigns to Name, not to a backing field named _name. The compiler allows this for get-only properties within the constructor body, exactly as it does for get-only auto-properties. The assignment bypasses the getter — it goes straight to the synthesized backing field — so the trimming in the getter has no effect at assignment time. It only runs when the property is read.
This is an important behavioral detail: accessor logic only runs when the accessor runs. An assignment in a constructor goes directly to field, not through any setter you may or may not have defined. If you need your normalization or validation logic to run at construction time too, you need to either apply it explicitly in the constructor or use an init accessor instead.
Get-Only with Field Initializers
You can combine a get-only semi-auto property with a field initializer just as you would with an auto-property:
public class Session
{
public Guid SessionId
{
get => field;
} = Guid.NewGuid();
}Code language: C# (cs)The initializer runs before the constructor body, pre-populating field with a new Guid. The property is read-only from that point on. This is functionally identical to public Guid SessionId { get; } = Guid.NewGuid(); except that you now have the option to add getter logic around field if you ever need to. In this particular example the getter is trivial, but it’s easy to imagine adding formatting or transformation later without having to restructure the property.
init Accessors and field
We touched on init in the previous section, but it’s worth going deeper here because the interaction between init and field has a few nuances that matter in practice.
An init accessor behaves like a set accessor during object initialization and like no accessor at all afterward. From the compiler’s perspective, field inside an init accessor is writable during the initialization phase and then implicitly read-only once the object is fully constructed. This means you get the same guarantees as a get-only property — immutability after construction — but with the ability to set the value via object initializer syntax rather than only through a constructor.
public class Address
{
public string Street
{
get;
init => field = value.Trim();
}
public string City
{
get;
init => field = value.Trim();
}
}
// Usage:
var address = new Address
{
Street = " 123 Main St ",
City = " Springfield "
};
Console.WriteLine(address.Street); // "123 Main St"
Console.WriteLine(address.City); // "Springfield"Code language: C# (cs)The trimming happens in the init accessor when the object is initialized, and the cleaned values are what get stored in field. After that, neither Street nor City can be reassigned.
Combining get and init with Separate Logic
One pattern that becomes genuinely expressive with semi-auto properties is having distinct logic in both the get and init accessors:
public class Product
{
public string Sku
{
get => field.ToUpperInvariant();
init
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("SKU cannot be blank.", nameof(value));
field = value.Trim();
}
}
}Code language: C# (cs)Here the init accessor validates and trims the input before storing it, and the get accessor uppercases on every read. You’re storing a trimmed-but-original-case value and presenting it as uppercase. Whether you’d prefer to store it uppercase and present it as-is is a design decision — the point is that field gives you independent control over what gets stored and what gets returned, without any separately declared backing field.
required and init Together
Combining required with an init semi-auto property gives you a pattern that enforces construction-time assignment, validates the input, and guarantees immutability — all in one property declaration:
public class Customer
{
public required string Email
{
get;
init
{
if (!value.Contains('@'))
throw new ArgumentException("Invalid email address.", nameof(value));
field = value.ToLowerInvariant();
}
}
}Code language: C# (cs)The required modifier means the compiler will enforce that Email is set in any object initializer that constructs a Customer. The init accessor validates and normalizes the value when it’s set. And once construction is complete, Email is frozen. You get compiler enforcement, runtime validation, and immutability, and the whole thing fits in eight lines with no separate backing field.
This combination — required, init, and field — is probably the most practically useful pattern the feature enables for anyone building domain models, DTOs, or value objects. It’s worth having in your toolkit.
7. Attribute Targeting: Applying Attributes to the Synthesized Backing Field
One of the quieter pain points with auto-properties has always been attributes. Specifically: what do you do when you need to apply an attribute to the backing field rather than the property itself?
With a full property and an explicit backing field, this is trivial — you just decorate the field directly. With an auto-property, the backing field is compiler-generated and invisible, so you have to use the field: attribute target to reach it:
[field: NonSerialized]
public int CachedValue { get; set; }Code language: C# (cs)This syntax has existed since C# was young, but it’s always felt a little awkward because it requires you to know that the compiler generated a field, know its target specifier, and trust that the attribute will land in the right place. More importantly, it only works for auto-properties — the moment you converted to a full property with an explicit backing field, you’d go back to decorating the field directly and the field: target syntax became irrelevant.
Semi-auto properties change this. Because the backing field is still compiler-generated even when you write custom accessor logic, the field: attribute target remains the correct and only way to decorate it. This actually makes the attribute story for semi-auto properties more consistent than it’s ever been for auto-properties, because now the same syntax works across a much broader range of property shapes.
The field: Target Specifier
The syntax is the same as it was for auto-properties:
public class UserSession
{
[field: NonSerialized]
public DateTime LastAccessed
{
get;
set => field = value;
}
}Code language: C# (cs)The [field: NonSerialized] attribute is applied to the synthesized backing field, not to the property itself. The property remains visible and serializable as far as property-level serialization is concerned — it’s specifically the field that gets the attribute. This distinction matters depending on which serialization mechanism you’re using and whether it inspects fields, properties, or both.
Common Use Cases
JSON serialization with System.Text.Json or Newtonsoft.Json
If you’re using a serializer that can be configured to serialize fields as well as properties, you may want to exclude the backing field explicitly to avoid double-serialization:
public class ApiResponse
{
[field: System.Text.Json.Serialization.JsonIgnore]
public string RawPayload
{
get => field;
set => field = value?.Trim();
}
}Code language: C# (cs)In most System.Text.Json configurations you won’t need this because field serialization is opt-in, but in Newtonsoft.Json with MemberSerialization.Fields or similar settings, it becomes relevant.
Debugger visibility
The synthesized backing field will show up in the debugger’s locals and watch windows under a compiler-generated name — something like <RawPayload>k__BackingField. If you’re debugging a type with several semi-auto properties and the backing fields are cluttering your watch window, you can suppress them:
public class ViewModel
{
[field: System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)]
public string Title
{
get => field ?? "Untitled";
set => field = value;
}
}Code language: C# (cs)This is the same technique used by auto-property backing fields internally — the compiler applies DebuggerBrowsable(Never) to its generated fields automatically. With semi-auto properties, it doesn’t do this by default, so if debugger cleanliness matters to you, you can apply it manually.
ThreadStatic and SkipLocalsInit
For more advanced scenarios, you can apply attributes like [field: ThreadStatic] to make the backing field thread-local, though this is an unusual pattern for a property and you should be deliberate about it. Similarly, [field: SkipLocalsInit] is technically applicable but unlikely to be meaningful in practice for most property patterns.
What You Cannot Do
The field: target only applies to the synthesized backing field. You cannot use it to apply attributes to the property’s getter or setter — those have their own target specifiers (get: and set: or return: for the getter’s return value, though these are rarely used in practice). Mixing up these targets is a compile-time error, so the compiler will catch it early.
It’s also worth noting that the field: target only works when there is a synthesized backing field to target — meaning the property must be an auto-property or a semi-auto property. Applying [field: ...] to a computed property with no field reference and no auto-accessor will produce a compiler warning or error depending on the context, because there’s no backing field for the attribute to land on.
A Practical Recommendation
If you’re migrating auto-properties to semi-auto properties and those auto-properties already had [field: ...] attributes on them, the good news is that your attribute declarations don’t need to change at all. The field: target specifier works identically on both. The migration is purely a matter of adding accessor logic and switching from implicit get/set to explicit bodies — the attribute targeting layer is completely unaffected.
8. field in Structs, Records, and Record Structs
So far all of our examples have used classes, which is where most developers will reach for semi-auto properties first. But field works across all type kinds in C#, and each one has its own behavioral nuances worth understanding before you use the feature in a struct or record context.
Structs
Structs have always had a slightly awkward relationship with properties. Before C# 10, a struct couldn’t even have auto-properties with custom logic in certain contexts without running into definite assignment issues. Things have improved considerably since then, but structs still carry rules that classes don’t, and semi-auto properties surface a couple of them.
The most important rule: structs are value types, and mutation through a property on a copied struct won’t affect the original. This isn’t specific to semi-auto properties — it’s a fundamental struct behavior — but it’s worth stating explicitly because field can make property-based mutation look deceptively clean:
public struct Temperature
{
public double Celsius
{
get => field;
set => field = Math.Round(value, 2);
}
}Code language: C# (cs)This works exactly as you’d expect when you’re working with a direct reference to the struct. Where it bites you is in scenarios like this:
Temperature[] readings = new Temperature[5];
readings[0].Celsius = 98.6; // fine, modifies array element directly
Temperature t = readings[0];
t.Celsius = 100.0; // modifies the copy, not readings[0]Code language: C# (cs)Nothing about this behavior is new or specific to field — it’s how structs have always worked. But because semi-auto properties make property logic look so lightweight and clean, it’s easy to forget you’re working with a value type and accidentally mutate a copy. Keep your usual struct discipline in place.
Definite assignment in structs is the other area to watch. In a struct, the compiler requires that all fields be definitely assigned before the constructor completes. With auto-properties, the compiler could reason about this easily because it knew the property and its backing field were in a one-to-one relationship. With semi-auto properties, the same relationship holds — assigning to a semi-auto property in a constructor assigns directly to the synthesized backing field — so definite assignment works as expected:
public struct Point
{
public double X
{
get => field;
set => field = value;
}
public double Y
{
get => field;
set => field = value;
}
public Point(double x, double y)
{
X = x; // assigns to backing field for X
Y = y; // assigns to backing field for Y
}
}Code language: C# (cs)The compiler is satisfied that both backing fields are assigned in the constructor. If you forget to assign one, you’ll get the same definite assignment error you’d get with an explicit backing field.
Readonly structs add another layer. In a readonly struct, all fields must be read-only, which means a semi-auto property in a readonly struct can only have a get accessor or an init accessor — not a mutable set. Attempting to write a set accessor that assigns to field in a readonly struct will produce a compiler error, because the synthesized backing field would need to be mutable to support it, which contradicts the readonly declaration:
public readonly struct ImmutablePoint
{
public double X
{
get => field; // fine
init => field = value; // fine
// set => field = value; // compiler error
}
}Code language: C# (cs)This is consistent with how full properties work in readonly structs and shouldn’t be surprising, but it’s good to have it explicit.
Records
Records are where things get interesting, because the C# compiler generates a significant amount of code on your behalf for records — equality members, GetHashCode, ToString via PrintMembers, and for positional records, a deconstructor as well. Semi-auto properties interact with all of this generated code, and the interaction is largely seamless, but there are details worth knowing.
Equality and GetHashCode in records are generated based on the properties of the record, not its fields. This is an important distinction. The compiler-generated Equals and GetHashCode for a record call the property getters to get the values to compare and hash. That means if your semi-auto property’s getter applies a transformation — say, trimming or uppercasing — the equality comparison will use the transformed value, not whatever raw value is stored in field:
public record Person
{
public required string Name
{
get => field.Trim();
init => field = value;
}
}
var a = new Person { Name = "Alice" };
var b = new Person { Name = "Alice " }; // trailing spaces
Console.WriteLine(a == b); // true — both getters return "Alice"Code language: C# (cs)This might be exactly what you want — equality based on the normalized, meaningful value rather than the raw stored one. Or it might surprise you if you weren’t thinking about it. Either way, be deliberate: when you add getter logic to a record property, you’re also changing what equality means for that record.
PrintMembers and ToString follow the same rule. The generated ToString for a record calls property getters, so the output will reflect your getter transformation rather than the raw stored value. For debugging and logging purposes this is usually the right behavior, but again, worth being conscious of.
with expressions are the one area where the interaction requires more care. A with expression creates a copy of a record with specified properties changed. Under the hood, the compiler uses the property’s init accessor (or setter, for mutable records) to apply the changed values to the copy. This means your init logic — validation, normalization, whatever you’ve put in there — runs again during a with expression:
public record Product
{
public required string Name
{
get;
init
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Name cannot be blank.");
field = value.Trim();
}
}
}
var original = new Product { Name = "Widget" };
var renamed = original with { Name = " Gadget " }; // init runs, trims to "Gadget"
var invalid = original with { Name = "" }; // throws ArgumentExceptionCode language: C# (cs)This is generally the correct behavior — you want your invariants enforced on with just as they are on initial construction. But if your init accessor has side effects beyond simple validation and normalization, be aware that with will trigger them.
Positional Records
Positional record properties are auto-generated by the compiler from the primary constructor parameters:
public record Point(double X, double Y);Code language: C# (cs)These generated properties are standard auto-properties. You cannot directly attach field-based accessor logic to a positional property inline in the primary constructor syntax. If you need a positional record property to have semi-auto behavior, you need to override it explicitly in the record body:
public record Point(double X, double Y)
{
public double X
{
get => field;
init => field = Math.Round(value, 4);
}
}Code language: C# (cs)This overrides the compiler-generated X property with your semi-auto version. The positional deconstructor still works — it calls the property getter, which returns field — and equality and ToString use the getter as well, so the rounded value is what participates in all record-generated behavior. The Y property remains a plain auto-property since you didn’t override it.
Record Structs
Record structs, introduced in C# 10, combine the value-type semantics of structs with the equality and ToString generation of records. Semi-auto properties in record structs follow both sets of rules discussed above: value-type mutation semantics apply, readonly record structs prohibit mutable setters, and record-generated equality calls property getters.
public readonly record struct Coordinate
{
public double Latitude
{
get => field;
init => field = Math.Clamp(value, -90, 90);
}
public double Longitude
{
get => field;
init => field = Math.Clamp(value, -180, 180);
}
}Code language: C# (cs)This is a clean and expressive pattern for small, immutable value objects. The clamping in the init accessors enforces geographic validity at construction time, the record generates correct equality and hashing based on the clamped values, and the readonly modifier on the record struct ensures nobody accidentally mutates a copy and wonders why the original didn’t change. It’s a good illustration of how several C# features layer together naturally once you understand what each one contributes.
9. Thread Safety Considerations
Thread safety and properties have always had an uneasy relationship in C#. Auto-properties gave developers a convenient way to expose state, but they never made any promises about concurrent access — and semi-auto properties don’t change that. Before reaching for field in a multithreaded context, it’s worth being clear about what the feature does and doesn’t give you.
What field Doesn’t Change
The synthesized backing field created by the compiler for a semi-auto property is a plain instance field. It gets no special treatment in terms of memory visibility, atomicity, or ordering. Concurrent reads and writes to it carry exactly the same risks as concurrent reads and writes to any other unsynchronized field — torn reads on types larger than the native word size, stale values due to CPU caching, and race conditions when your accessor logic involves more than a single memory operation.
This is not a criticism of the feature. Thread safety is a cross-cutting concern that C# has deliberately left to developers and synchronization primitives rather than baking into the language’s property model. The point is simply that field doesn’t add anything new here, and you shouldn’t expect it to.
Consider this seemingly harmless semi-auto property:
public class RequestCounter
{
public int Count
{
get => field;
set => field = value;
}
}Code language: C# (cs)If multiple threads are incrementing Count via counter.Count++, you have a race condition. The increment operation is not atomic — it’s a read, an increment, and a write — and the fact that it happens through a semi-auto property rather than a plain field makes no difference whatsoever. The same issue exists with a full property backed by an explicit field, and with a plain public field.
Lazy Initialization and Thread Safety
Lazy initialization is where thread safety concerns become most relevant for semi-auto properties, because the ??= pattern discussed in section 5 is not thread-safe:
public class ReportGenerator
{
public List<string>? Sections
{
get => field ??= LoadSections();
}
}Code language: C# (cs)If two threads call the Sections getter simultaneously and field is null for both of them, both will call LoadSections(). Whether that’s a problem depends on whether LoadSections has side effects and whether having two instances of the result floating around matters. In many cases — particularly for pure computation with no side effects — this kind of benign double-initialization is acceptable. But if LoadSections hits a database, fires an HTTP request, or has any observable side effect, you need a proper synchronization strategy.
The standard solution for thread-safe lazy initialization in C# is Lazy<T>, and it pairs naturally with semi-auto properties:
public class ReportGenerator
{
public Lazy<List<string>> Sections
{
get;
} = new Lazy<List<string>>(LoadSections, LazyThreadSafetyMode.ExecutionAndPublication);
private static List<string> LoadSections() { /* ... */ }
}Code language: C# (cs)This guarantees that LoadSections is called exactly once regardless of how many threads race to access Sections for the first time. The trade-off is that the property type is now Lazy<List<string>> rather than List<string>, so callers write generator.Sections.Value instead of generator.Sections. Whether that’s acceptable depends on your API design priorities.
If you want to hide the Lazy<T> from callers, a full property with an explicit Lazy<T> backing field is still the right tool:
public class ReportGenerator
{
private readonly Lazy<List<string>> _sections =
new Lazy<List<string>>(LoadSections, LazyThreadSafetyMode.ExecutionAndPublication);
public List<string> Sections => _sections.Value;
private static List<string> LoadSections() { /* ... */ }
}Code language: C# (cs)This is one of those cases where a semi-auto property genuinely doesn’t help — the explicit backing field isn’t boilerplate here, it’s load-bearing infrastructure. Recognizing when field adds value and when a named backing field is still the right choice is part of using the feature well.
Volatile and Interlocked
For simple scalar values where you need visibility guarantees across threads, volatile is the traditional answer. With an explicit backing field you can mark it volatile directly:
private volatile bool _isRunning;
public bool IsRunning => _isRunning;Code language: C# (cs)With a semi-auto property, you can’t apply volatile to the synthesized backing field because volatile is not an attribute — it’s a field modifier, and field modifiers aren’t expressible through the [field: ...] attribute target syntax. This means that for any property where you need volatile semantics on the backing field, you need a full property with an explicit backing field. It’s a genuine limitation of the feature in multithreaded contexts.
For atomic integer operations, Interlocked works at the level of the field reference, which isn’t accessible from outside the property body. You can use Interlocked operations inside an accessor:
public class RequestCounter
{
public int Count
{
get => field;
}
public void Increment() => Interlocked.Increment(ref field); // not valid
}Code language: C# (cs)This won’t work — field is only accessible inside property accessor bodies, not in methods. For Interlocked-based patterns you again need an explicit backing field. This is by design: field is intentionally scoped to accessors, and exposing the backing field reference to the broader class body would undermine the encapsulation the feature is meant to enforce.
A Practical Decision Framework
The thread safety question for semi-auto properties comes down to a simple set of cases:
If the property is read-only after construction and written only during initialization — which covers most immutable types, DTOs, and value objects — thread safety is a non-issue. Reads after construction are safe without synchronization as long as there’s a happens-before relationship between the write and the first read, which constructors and object initializers provide.
If the property is mutable and accessed from multiple threads, and all you need is visibility rather than atomicity, you need volatile on the backing field — which means a full property with an explicit field.
If you need atomic operations or lazy initialization with strong thread safety guarantees, Interlocked and Lazy<T> respectively are the right tools, and both require explicit backing fields to use properly with properties.
If the property is mutable and accessed from multiple threads and you’re using locks, the lock lives outside the property anyway, which means the property itself doesn’t need to do anything special — semi-auto or full, it makes no difference.
The pattern that semi-auto properties handle well in concurrent code is the one that needs no special handling at all: immutable-after-construction state, set via init or in a constructor, read freely afterward. For everything else, the existing tools still apply, and the cases where those tools require an explicit backing field are real limitations worth being upfront about.
10. Gotchas, Limitations, and Known Edge Cases
Every language feature has its rough edges, and field is no exception. Most of the gotchas here are predictable once you understand the design constraints the C# team was working within, but a few are genuinely subtle and worth calling out explicitly so they don’t bite you in production code or a code review.
The Name Shadowing Problem
This is the most commonly cited gotcha, and it’s a direct consequence of field being a contextual rather than reserved keyword.
If you have a parameter or local variable named field inside a property accessor, it shadows the keyword. The compiler will prefer the local identifier over the contextual keyword, meaning field no longer refers to the synthesized backing field — it refers to your variable:
public class Mapper
{
public string Value
{
get;
set
{
var field = value.Trim(); // local variable named 'field'
field = field; // assigns local to itself, not to backing field!
}
}
}Code language: C# (cs)This compiles without error, but it’s almost certainly not what you meant. The backing field never gets assigned, and Value will always return the default value for its type. The compiler does emit a warning when a local variable named field shadows the keyword inside an accessor, so you’ll be alerted — but warnings are easy to miss, especially in large codebases where warning counts are already high.
The fix is simple: rename the local variable to something that doesn’t conflict. But the real lesson is to treat field as mentally reserved inside property accessors, even though the language doesn’t enforce it. If your team adopts semi-auto properties widely, it’s worth adding a linting rule or code review note to flag local variables named field inside accessor bodies.
This shadowing issue also applies to method parameters in expression-bodied accessors, though that scenario is less common since accessors don’t take parameters directly — value is the implicit parameter in setters and init accessors, and field isn’t a parameter name you’d reach for naturally in most cases.
The Compiler-Generated Field Name
When the compiler synthesizes a backing field for a semi-auto property, it gives that field a name following the same convention used for auto-property backing fields: <PropertyName>k__BackingField. So for a property named Price, the synthesized field will be named <Price>k__BackingField at the IL level.
This matters in a handful of scenarios:
Reflection. If you’re using reflection to enumerate a type’s fields — for serialization, mapping, testing, or any other purpose — the synthesized backing field will show up under its compiler-generated name. Code that filters fields by name or naming convention needs to account for this. The field name is not stable across compiler versions either, though in practice the convention hasn’t changed in many years.
Binary serialization. If you’re using BinaryFormatter (though you shouldn’t be in new code) or any other serialization mechanism that persists field names, renaming the property will change the backing field’s name and break deserialization of previously serialized data. This is the same issue that exists with auto-properties and is not new, but it’s easy to forget.
Source generators and analyzers. If you’re writing or maintaining a source generator or Roslyn analyzer that inspects property backing fields, you need to handle the case where the backing field is synthesized rather than explicitly declared. The IPropertySymbol in Roslyn exposes a BackingField property that returns the synthesized field symbol for both auto-properties and semi-auto properties, so the API surface is there — but generators that work by inspecting field declarations directly rather than through the property symbol will miss synthesized fields.
Initializer Bypass
We mentioned this briefly in section 4, but it deserves explicit emphasis because it’s a source of real bugs. Property initializers and constructor assignments bypass accessor logic. When you write:
public class Product
{
public decimal Price
{
get;
set
{
if (value < 0) throw new ArgumentOutOfRangeException(nameof(value));
field = value;
}
} = -1; // initializer
}Code language: C# (cs)The initializer = -1 bypasses the setter entirely and writes directly to the backing field. You now have a Product with a Price of -1 before any constructor logic runs, which directly violates the invariant your setter is trying to enforce. The same applies to assignments in constructors for get-only properties — the assignment goes directly to the backing field, not through any accessor.
If you need invariants enforced at initialization time, you have two options: validate in the constructor explicitly after setting the property, or use an init accessor with a setter-style body that runs validation, and set the property via an object initializer rather than a field initializer.
Interaction with nameof
Inside an accessor body, nameof(field) does not return "field". It returns the compiler-generated field name, which is something like <Price>k__BackingField. This is almost certainly not what you want if you’re using nameof for error messages or logging:
public decimal Price
{
get;
set
{
if (value < 0)
throw new ArgumentOutOfRangeException(nameof(field)); // returns "<Price>k__BackingField"
field = value;
}
}Code language: C# (cs)Use nameof(value) for the parameter in setter exception messages, or the property name directly via nameof(Price), rather than nameof(field). This is a minor footgun but one that shows up in exception messages visible to developers and sometimes end users.
No Cross-Accessor Field References Outside Properties
field is strictly scoped to the accessor in which it appears. You cannot reference field from a method, a constructor body (beyond the implicit assignment behavior for get-only properties), or any other context outside a property accessor. This is by design, but it creates a genuine limitation for patterns where you want to reset or directly manipulate the backing value from within the class without going through the property’s accessor logic.
For example, if your setter validates and normalizes input, but you have an internal method that needs to set the backing value to a known-valid state without triggering that logic, you have no way to do that with a semi-auto property. You need a full property with an explicit backing field that your internal method can reach directly. This is a real and intentional trade-off — the encapsulation of field within the accessor body is what makes semi-auto properties safe and predictable — but it’s a limitation to be aware of.
LangVersion Mismatches and Silent Failures
If your project’s LangVersion is set below 13 and you write code that uses field inside a property accessor, the compiler won’t treat it as the backing field keyword — it will treat it as a regular identifier. Depending on context, this could mean it compiles successfully but does something completely wrong, or it produces an error because field as an identifier isn’t declared anywhere in scope.
The insidious case is when field happens to be a valid identifier in scope — a field or local variable of that name somewhere in the type — and the code compiles cleanly but behaves incorrectly because field is resolving to the wrong thing entirely. If you’re working in a multi-project solution with different LangVersion settings, or if you copy semi-auto property code into a project targeting an older language version, this is worth checking explicitly. A quick look at the project file’s <LangVersion> element will tell you whether the feature is available.
Serialization Frameworks and Field Discovery
Several popular serialization frameworks — Newtonsoft.Json with certain settings, MessagePack, MemoryPack, and others — can be configured to serialize fields rather than or in addition to properties. When these frameworks discover fields via reflection, they will find the synthesized backing field for semi-auto properties and may attempt to serialize it, producing duplicate data in the output (once from the field, once from the property) or unexpected JSON keys based on the compiler-generated field name.
The solution is the [field: JsonIgnore] or equivalent attribute targeting discussed in section 7. But the point here is that this isn’t just a theoretical concern — it’s an active gotcha for anyone migrating from auto-properties or full properties to semi-auto properties in a codebase that uses field-level serialization. Audit your serialization configuration and test your serialized output when migrating.
One Thing field Simply Cannot Do
It’s worth ending this section with a clear statement of the feature’s fundamental constraint: field gives you access to a single, compiler-managed backing store for the property. You cannot have two synthesized fields for one property, you cannot control the field’s type independently of the property type, and you cannot give the field a name that’s meaningful outside the property body. If any of those things matter for your use case, a full property with one or more explicitly declared backing fields is the right tool. Semi-auto properties are not trying to replace that pattern — they’re trying to eliminate the cases where you reach for it out of habit or necessity when you don’t actually need that level of control.
11. Tooling and IDE Support
A language feature is only as usable as the tooling around it, and it’s worth knowing what Visual Studio, JetBrains Rider, and the underlying Roslyn compiler actually offer when you’re working with semi-auto properties day to day. The short version is that support is solid, with a few rough edges you should know about before you lean on the tooling too heavily.
Roslyn Analyzer Support
The Roslyn compiler ships with a set of built-in analyzers that understand semi-auto properties at a semantic level. The most immediately useful of these is the analyzer that detects when a full property with an explicit backing field could be simplified to a semi-auto property. In Visual Studio and any editor running the Roslyn language server, this surfaces as a suggestion — typically a grey underline on the backing field declaration — with a quick fix action that performs the migration automatically.
The suggested refactoring handles the straightforward cases well: it removes the backing field, rewrites the getter and setter to use field, and cleans up any now-unnecessary field references. For simple validation and normalization patterns, the automatic migration is reliable and worth using. For more complex patterns — particularly where the backing field is referenced from multiple places in the class, or where the field has attributes that need to be migrated to [field: ...] syntax — the analyzer is more conservative and may not offer the refactoring at all, which is the correct behavior. An analyzer that tries to be too clever about complex migrations causes more problems than it solves.
The shadowing warning discussed in section 10 — where a local variable named field inside an accessor shadows the keyword — is also surfaced by Roslyn as a compiler warning rather than just a style suggestion. This means it will appear in your build output and can be treated as a build error if your project uses <TreatWarningsAsErrors>true</TreatWarningsAsErrors>. For teams adopting semi-auto properties at scale, this is worth enabling or at least monitoring.
Visual Studio
In Visual Studio 17.8 and later (the versions aligned with C# 13 preview and release), IntelliSense is fully aware of the field keyword inside property accessor bodies. It offers completion for field, shows the correct type information in hover tooltips, and navigates correctly when you use “Go to Definition” on field — though since there’s no explicit declaration to navigate to, it typically highlights the property declaration itself, which is the most useful thing it can do.
The “Refactor” menu includes the migration action mentioned above when the analyzer detects a convertible pattern. You can also trigger it via the lightbulb menu (Ctrl+.) when your cursor is on the backing field or the property declaration. The rename refactoring is unaffected by semi-auto properties in any meaningful way — renaming the property renames the property, and the synthesized backing field’s compiler-generated name updates automatically since it’s derived from the property name.
One area where Visual Studio’s support is still maturing is the debugger experience. The synthesized backing field shows up in the Locals and Autos windows under its compiler-generated name during debugging, which is cluttered and rarely useful. Applying [field: DebuggerBrowsable(DebuggerBrowsableState.Never)] as discussed in section 7 is currently a manual step — Visual Studio doesn’t automatically suppress synthesized backing fields the way it does for auto-property backing fields. This is a tooling gap that will likely close in a future Visual Studio update, but for now it’s worth being aware of if you do a lot of property-level debugging.
JetBrains Rider
Rider’s support for semi-auto properties follows its usual pattern of being slightly ahead of Visual Studio on language feature adoption. The ReSharper-based engine that powers Rider’s code analysis understands field semantically and offers the same class of migration suggestions as Roslyn, with the addition of Rider’s more aggressive inspection suite which can catch a broader range of convertible patterns.
Rider’s parameter information and type inference hints work correctly for field — hovering over field in an accessor body shows the inferred type, and the inline type hint feature (if you have it enabled) displays the type next to field in the editor, which is useful when the property type is a long generic name and you want a quick reminder of what you’re working with without scrolling to the property declaration.
The debugger integration in Rider handles the synthesized backing field slightly more gracefully than Visual Studio out of the box, grouping compiler-generated members separately in the debug view rather than mixing them with explicitly declared fields. This doesn’t eliminate the noise entirely, but it makes it easier to find what you’re looking for during a debugging session.
What the Decompiler Reveals
If you open a compiled assembly containing semi-auto properties in ILSpy, dnSpy, dotPeek, or the decompiler built into Rider, you’ll see the synthesized backing field exposed as a regular private field with its compiler-generated name. The decompiler will typically round-trip the code back to a full property with an explicit backing field rather than reconstructing the semi-auto syntax, because the compiled IL contains no metadata indicating that the property was originally written as a semi-auto property — that information exists only in source.
This is worth knowing for two reasons. First, if you’re inspecting third-party code in a decompiler and see a full property with a k__BackingField-named field, it might have been written as a semi-auto property originally — you can’t tell from the IL alone. Second, the decompiled output gives you a useful sanity check on what the compiler is actually generating. For most semi-auto properties, the decompiled output is identical to what you would have written as a full property before the feature existed, which confirms that there’s no runtime overhead or behavioral difference — it’s purely a source-level convenience.
For IL-level inspection, the synthesized backing field is marked with [CompilerGenerated] just like auto-property backing fields, which is how tools and frameworks can distinguish synthesized fields from explicitly declared ones when they need to. If you’re writing tooling that inspects assemblies, filtering on [CompilerGenerated] is the right approach rather than relying on naming conventions.
dotnet format and EditorConfig
At the time of writing, dotnet format doesn’t enforce any specific style rules around semi-auto property usage — it won’t automatically migrate full properties to semi-auto properties as part of a format pass. Style preferences around when to use semi-auto properties vs. full properties are currently expressed through Roslyn analyzer severity settings in .editorconfig rather than through formatting rules. If your team wants to enforce consistent usage, the place to configure that is the analyzer severity for the relevant diagnostic IDs, which you can set to suggestion, warning, or error depending on how strictly you want to enforce the pattern.
12. When to Use Semi-Auto Properties vs. Full Properties
After nine sections of detail, it’s worth stepping back and answering the practical question every developer will eventually ask standing in front of a property declaration: should I use field here, or just write a full property?
The honest answer is that for most cases the decision is obvious once you’ve internalized what the feature is for. Semi-auto properties solve a specific problem — compiler-managed backing storage with custom accessor logic — and the cases where they’re the right tool are easy to recognize. The cases where a full property is still warranted are equally clear, and it’s important not to reach for field out of novelty when a named backing field is genuinely the better choice.
Use Semi-Auto Properties When
You need one thing from a backing field: storage. If the only reason you’d declare a private backing field is to have somewhere to put the value, and everything else about the property is standard get/set or get/init behavior with some logic wrapped around it, field is the right choice. The field name adds no value, the declaration adds no clarity, and removing both makes the code strictly easier to read.
The logic is self-contained within the accessor. Validation, normalization, trimming, clamping, coalescing — any logic that lives entirely inside the getter or setter without needing to reach outside the property — is a natural fit. The encapsulation that field enforces, where the backing store is only accessible through the accessors, is a feature rather than a limitation in these cases. It guarantees that your accessor logic is the only path to the stored value.
You’re working with immutable or init-only properties. The combination of required, init, and field is probably the most compelling use case for the feature in everyday application code. Domain entities, value objects, DTOs with validation — anything that needs to be set once, validated on the way in, and immutable afterward is an ideal candidate. The pattern is expressive, the intent is clear, and it requires no supporting infrastructure.
You’re migrating an existing auto-property that grew a small amount of logic. This is the most common real-world trigger. You had a clean { get; set; } and a new requirement arrived — a range check, a default value, a normalization step. Previously your only option was a full migration to a backing field. Now you can add the logic you need without restructuring the property at all. If the Roslyn analyzer offers you the migration automatically, take it.
Use a Full Property When
The backing field needs to be accessed from outside the property’s accessors. If a method, constructor, or other property in the same type needs to read or write the backing value directly — bypassing the property’s accessor logic — you need a named field. There’s no workaround here: field is strictly accessor-scoped, and that’s not going to change. Any pattern involving Interlocked, direct field manipulation for performance reasons, or internal reset logic that bypasses validation falls into this category.
You need more than one backing value. A property that computes its result from two private fields, or that maintains both a raw and a processed version of its value, needs explicit field declarations. field gives you exactly one synthesized backing store per property, and you can’t get around that limit.
You need volatile on the backing field. As covered in section 9, volatile is a field modifier rather than an attribute, so it can’t be applied through the [field: ...] target syntax. Any property in a multithreaded context that requires volatile semantics needs a full property with an explicit backing field.
The backing field type needs to differ from the property type. The synthesized field always has the same type as the property. If you’re storing a Lazy<T> behind a T-typed property, or an int behind an enum-typed property, or anything else where the stored type and the exposed type diverge, you need an explicit backing field with the correct type.
The field has meaningful semantics beyond just storing the property value. If the backing field participates in serialization in a way that requires careful naming, or if it needs to be a specific named member for interop reasons, or if its existence as a named class member is itself meaningful to the design, an explicit declaration communicates that intentionality. Compiler-generated fields are implementation details; named fields are part of the type’s structure.
The Rule of Thumb
Here’s a simple test that covers the majority of cases: if you would name the backing field _propertyName and use it for nothing other than storing and returning the property’s value, use field. If you’d name it something else, or use it for anything beyond simple storage, use a full property.
The naming test works because a backing field named _price for a property named Price is purely mechanical — it exists because the language required it, not because the name or the explicit declaration adds anything. That’s exactly the case field is designed to eliminate. A backing field named _cachedResult, _lazyLoader, _rawBytes, or anything other than a straightforward underscore-prefixed version of the property name is carrying semantic weight that deserves to be explicit.
Semi-auto properties are a quality-of-life improvement for a specific, common pattern. They’re not a replacement for full properties, and treating them as a default you apply everywhere will lead to code that works but reaches for field in cases where a named backing field would communicate intent more clearly. Use the feature where it genuinely simplifies things, and reach for a full property without hesitation when the situation calls for it.
13. Conclusion
Semi-auto properties and the field keyword are a small addition to C# in the grand scheme of language evolution. There’s no new runtime behavior, no new type system concept, no shift in how you think about object-oriented design. What there is, is the elimination of a specific piece of friction that C# developers have been working around since auto-properties were introduced in 2007.
That friction — the all-or-nothing choice between a convenient auto-property and a verbose full property — was never catastrophic. Developers wrote the backing fields, named them carefully, kept them in sync, and moved on. But the cost was real: noise in the codebase, surface area for bugs, and a subtle pressure against adding validation or normalization logic because the structural overhead felt disproportionate to the change.
field removes that pressure. The backing field is still there — the compiler generates it — but you don’t see it, name it, or maintain it. You write the logic you actually care about, and the rest is handled for you. The result is code that more directly expresses intent, with fewer moving parts between what you write and what you mean.
The feature lands especially well in combination with patterns C# has been building toward for several versions: init accessors from C# 9, required members from C# 11, and the broader push toward more expressive, less ceremonial type declarations that has characterized the language’s recent evolution. Put those together and you have a genuinely compelling model for immutable-by-default types with construction-time validation — domain entities, value objects, and DTOs that enforce their own invariants without frameworks or base classes to lean on.
If there’s one thing to take away from this tutorial, it’s the rule of thumb from the previous section: if you’d name the backing field _propertyName and use it for nothing other than storing the property’s value, field is the right call. Everything else stays a full property. That simple test will get you to the right answer in the vast majority of cases, and the rest of the nuance — thread safety, struct semantics, record equality, attribute targeting — is there when you need it.
C# continues to be a language that takes its existing user base seriously, adding expressiveness without breaking what came before. Semi-auto properties are a good example of that philosophy done well: a pragmatic, backward-compatible improvement to a pattern that every C# developer has written hundreds of times. It won’t change how you think about the language, but it will quietly make your code a little cleaner, one property at a time.
