C# Positional Record: Syntax and Behavior
c# positional record: Learn how C# positional records work: constructor generation, value equality, deconstruction, and when to use them over classes.
C# positional records give you a compact way to declare a record type where the primary constructor parameters become public properties. Introduced in C# 9, they are a natural fit for immutable data models that need value-based equality. The syntax is minimal, but the compiler generates a surprising amount of behavior from it.
Positional Record Syntax and Generated Members
A positional record is declared with a parameter list directly after the type name:
public record Person(string FirstName, string LastName);
This one line produces a full record type. The compiler generates:
- A primary constructor that assigns the parameters to properties.
- Public init-only properties for each parameter, making the record immutable after construction.
- A
ToString()override that prints the type name and property values. - Value-based equality members, including
Equals,GetHashCode, and==/!=operators. - A protected copy constructor used by the
withexpression. - A
Deconstructmethod that supports tuple-style deconstruction.
You get all of this without writing a body. The generated code is equivalent to a more verbose record declaration with explicit properties, but the positional form is more concise and reduces boilerplate.
How the Compiler Expands a Positional Record
Understanding what the compiler generates helps you predict behavior. For the Person record above, the compiler effectively creates:
public record Person { public Person(string FirstName, string LastName) { this.FirstName = FirstName; this.LastName = LastName; } public string FirstName { get; init; } public string LastName { get; init; } }
The properties are init-only, so they can be set during object initialization and in with expressions, but not after the object is fully constructed. This immutability is a core reason to use records for data transfer objects, API responses, or domain values that should not change.
If you need mutable properties, you can override the property declaration with set; instead of init;, but that defeats the primary purpose of records. For mutable data, a regular class is usually more appropriate.
Deconstruction and with Expressions
Two features work seamlessly with positional records: deconstruction and the with expression.
Deconstruction lets you split a record into its components:
var person = new Person("Ada", "Lovelace"); var (first, last) = person; Console.WriteLine(first); // Ada
The generated Deconstruct method matches the parameter order, so the first variable gets FirstName and the second gets LastName. This is handy when you need to pass individual values to other methods or when working with tuple-like data.
The with expression creates a copy of the record with specified properties changed:
var original = new Person("Ada", "Lovelace"); var renamed = original with { LastName = "Byron" };
This uses the protected copy constructor and then applies the property changes. The original remains unchanged, reinforcing immutability. The with expression works on any record, not just positional ones, but positional records make it especially convenient because the properties are already declared.
Value Equality and Hash Codes
Records use value-based equality, not reference equality. Two records are equal if they are of the same type and all their property values are equal. This is a significant departure from classes, where equality is reference-based by default.
var a = new Person("Ada", "Lovelace"); var b = new Person("Ada", "Lovelace"); Console.WriteLine(a == b); // True Console.WriteLine(a.Equals(b)); // True Console.WriteLine(ReferenceEquals(a, b)); // False
The compiler generates Equals and GetHashCode that compare all properties. For positional records, the property list is exactly the primary constructor parameters. This makes records suitable for use as dictionary keys or for comparing data from different sources without writing custom equality logic.
One subtlety: the generated GetHashCode uses the property values. If you have a mutable property (which you shouldn't in a record), the hash code could change after the object is placed in a hash-based collection, breaking its contract. Stick with immutable records to avoid this issue.
Inheritance and Derived Positional Records
Positional records support inheritance. A derived record can add its own parameters and pass values to the base constructor:
public record Person(string FirstName, string LastName); public record Employee(string FirstName, string LastName, int Id) : Person(FirstName, LastName);
The derived record inherits the base properties and adds its own. Equality for derived records includes all properties from both the base and derived parts. Two Employee instances are equal only if their FirstName, LastName, and Id all match.
Deconstruction also works with inheritance. An Employee deconstructs into three values: FirstName, LastName, and Id. The base record's deconstruct method is not called separately; the compiler generates a new one for the derived type that includes all properties.
When you use a with expression on a derived record, the copy preserves the derived type. For example:
Employee emp = new("Ada", "Lovelace", 1); Employee updated = emp with { Id = 2 };
The result is an Employee, not a Person, because the compiler uses the copy constructor of the actual runtime type.
Choosing Between Positional Records and Regular Records or Classes
Positional records are not always the right choice. The decision depends on how much control you need over the type's behavior.
Use a positional record when:
- The type is primarily a data container with a fixed set of properties.
- You want value equality without writing
EqualsandGetHashCode. - You need immutable objects with concise syntax.
- Deconstruction is a natural way to consume the data.
Use a regular record (with explicit property syntax) when:
- You need to add custom logic to property getters or setters.
- You want to include additional members that are not part of the constructor.
- You need to validate parameters in the constructor and throw on invalid input.
Use a class when:
- You need reference equality.
- The object has behavior and mutable state.
- You rely on inheritance with polymorphic behavior that records cannot express cleanly.
For example, a domain entity with an identity and mutable state is better as a class. A DTO that carries data between layers is often a positional record.
Practical Considerations: Performance and Maintainability
Records do not introduce runtime overhead beyond what a manually written class would have. The compiler generates the equality methods and ToString as regular code, so there is no reflection at runtime. The only cost is the size of the generated code, which is negligible for most applications.
Maintainability is a tradeoff. Positional records are concise, but the generated behavior is implicit. Changing the order of parameters changes the constructor signature, deconstruction order, and equality comparison. If you reorder parameters, code that relies on positional deconstruction will break silently at runtime, because the variable types might still match but the values would be swapped. This is a real risk in large codebases.
To mitigate this, consider using named arguments when constructing records, and avoid relying on deconstruction for records with many parameters. For records with more than a few properties, the explicit property syntax might be clearer, even if it is more verbose.
Another consideration is compatibility. Positional records are a C# 9 language feature. They compile to regular classes and do not require a specific runtime, so you can use them on .NET Core 3.1 or .NET Framework with a modern compiler. However, the init accessor and with expression rely on IsExternalInit and Record support that the compiler synthesizes. In older runtimes, you may need to add a polyfill for IsExternalInit to use init accessors. This is a build-time detail, not a runtime requirement, but it can affect projects targeting older frameworks.
Finally, remember that records are not a performance optimization. They are a productivity feature. If you need to maximize throughput in a hot path, measure the actual impact of equality checks or ToString calls. In most cases, the generated code is as fast as hand-written code, but if you have specific performance requirements, you can always override the generated members with custom implementations.