A C# record is a lightweight, data-centric type designed primarily for storing and representing values rather than implementing behavior. Introduced in C# 9, the record keyword simplifies the creation of immutable, value-based classes ideal for data modeling. With C# 10, records were enhanced with two new syntaxes: record class (equivalent in behavior to C# 9 records) and record struct, which enables structure-based records for more efficient memory and performance handling.
This tutorial explores the syntax, use cases, and advantages of C# records.
Declaring a Record Type
In C# 9, Microsoft introduced the primary constructor syntax, offering a concise way to define a record’s constructor along with its properties. This approach minimizes boilerplate by removing the need to explicitly declare constructor parameters and assign them to properties;
|
1 2 3 4 5 |
record Person(string FirstName, string LastName); ... var person = new Person("Bill", "Gates"); Assert.IsTrue(person.FirstName == "Bill"); Assert.IsTrue(person.LastName == "Gates"); |
The Primary Constructor syntax is optional and a C# record can be declared without it:
|
1 2 3 4 5 6 7 8 |
record Person { public Person(string firstName, string lastName) { this.FirstName = firstName; this.LastName = lastName; } public string FirstName { get; } public string LastName { get; } } |
The Primary Constructor syntax also applies to record struct:
|
1 |
record struct Point(int X, int Y); |
Comparing Records with Classes in C#: Value-Based Semantics
So far, nothing sets a record class apart from a regular class. The difference is that a C# record uses value-based semantics, driven by two key characteristics: immutability and value-based equality.
Immutability
By default, a record class declared with the Primary Constructor syntax is immutable. This means that its instances’ state cannot be modified after creation, ensuring data integrity and thread safety:
The tooltip shows the compiler-generated property FirstName { get; init; } created by the primary constructor record syntax. Note that this behavior differs for record structs, which we’ll cover later.
Value-based equality
Value-based equality means that two C# record instances are considered equal if their data is identical. In contrast, regular classes use reference equality, which compares object references rather than their contents. Here’s an example demonstrating equality with records:
|
1 2 3 4 |
var record1 = new Person("Bill", "Gates"); var record2 = new Person("Bill", "Gates"); Assert.IsFalse(ReferenceEquals(record1, record2)); // Different objects ... Assert.IsTrue(record1 == record2); // ... but considered as equal because <strong>their data matches</strong> |
To achieve Value-based equality on record class the compiler overrides the virtual methods Object.Equals(Object) and Object.GetHashCode(). The compiler also overrides the operators == and !=.
Practical Application of Value-Based Semantics
As a .NET developer, you rely implicitly on value-based semantics daily. Indeed the class System.String adheres to Value-Based Semantics.
|
1 2 3 4 5 6 7 8 9 10 11 |
var string1 = "Bill Gates"; var string2 = string1.Substring(0, 5) + string1.Substring(5); // Clone string1 into another string object // string Value-Base Equality Assert.IsFalse(ReferenceEquals(string1, string2)); // Different string objects ... Assert.IsTrue(string1 == string2); // ... considered as equal because their data matches // string Immutability var string3 = string1.Replace("Gates", "Clinton"); // When modifying a string, a new string object is created Assert.IsFalse(ReferenceEquals(string1, string3)); Assert.IsTrue(string1 == "Bill Gates"); // string1 hasn't been modified |
Non-destructive record mutation with the keyword ‘with’
The code above shows that changing a string produces a new string object containing the updated value, leaving the original string unchanged. This behavior, called non-destructive mutation, is also supported for C# records using the with keyword:
|
1 2 3 4 |
var record1 = new Person("Bill", "Gates"); var record3 = record1 with { LastName = "Clinton" }; Assert.IsFalse(ReferenceEquals(record1, record3)); // When modifying a string, a new string object is created Assert.IsTrue(record1 == new Person("Bill", "Gates")); // record1 hasn't been changed |
When decompiling this C# code above with a .NET decompiler, we can see that the compiler generates a method named <Clone>$() for our record class. The nondestructive mutation is achieved by first cloning the record object and then assigning the property LastName. Normally this property cannot be assigned because the record is immutable. However special IL code generated by the C# compiler relying on IsExternalInit can assign it.
StringBuilder was designed to solve this. Likewise, non-destructive record mutations can also impact performance.Finally you can easy clone a record this way:
|
1 2 3 |
var record4 = record1 with { }; // record4 is a copy of record1 Assert.IsFalse(ReferenceEquals(record1, record4)); Assert.IsTrue(record1 == record4); |
Declaring mutable record class
Let’s mention that it is possible to declare mutable record classes when not relying on the Primary Constructor syntax:
|
1 2 3 4 5 6 7 8 |
var record1 = new Person { FirstName = "Bill", LastName = "Gates" }; record1.LastName = "Clinton"; Assert.IsTrue(record1 == new Person { FirstName = "Bill", LastName = "Clinton" }); public record class Person { public string FirstName { get; set; } public string LastName { get; set; } } |
Formatting
Another difference between C# classes and C# records is that the compiler overrides the Object.ToString() method to present a record as a string in a well-formatted manner:
|
1 2 |
var person = new Person("Bill", "Gates"); Assert.IsTrue(person.ToString() == "Person { FirstName = Bill, LastName = Gates }"); |
The same string representation is available at debug time:
C# record struct vs. C# record class
Unlike record class, by default a record struct is mutable. This means that a setter is generated for each property declared through the Primary Constructor syntax:
|
1 2 3 4 5 |
var point = new Point(1, 2); point.X = 3; // Possible to change the state of the point Assert.IsTrue(point == new Point(3, 2)); // Regular Value-based equality for a structure record struct Point(int X, int Y); |
To get an immutable record struct the keyword readonly must be used in readonly record struct:
Another characteristic of record struct is that they include a default parameterless constructor, which initializes each field to its default value:
|
1 2 |
var point = new Point(); Assert.IsTrue(point == new Point(0, 0)); |
Record deconstruction
When using the Primary Constructor syntax, the compiler generates a Deconstruct() method with an out parameter for each positional parameter provided in the record declaration. Here is record deconstruction in action:
|
1 2 3 4 |
var person = new Person("Bill", "Gates"); var (FirstName, LastName) = person; // Deconstruct the record person Assert.IsTrue(FirstName == "Bill"); Assert.IsTrue(LastName == "Gates"); |
Notice that deconstruction makes records work seamlessly with C# Pattern Matching:
|
1 2 3 4 5 6 7 8 9 |
var person = new Person("Bill", "Gates"); Assert.IsTrue(WhoIsThis(person) == "Billionaire Entrepreneur"); static string WhoIsThis(Person person) => person switch { ("Bill", "Gates") => "Billionaire Entrepreneur", ("Bill", "Clinton") => "US president", (_,_) => "Who is this person?" }; |
Record and Inheritance
record class can deal with regular C# class inheritance. A C# record can inherit from another C# record. Nevertheless, a record cannot inherit from a class, nor can a class inherit from a record.
|
1 2 3 4 5 6 7 |
public record Person { public string FirstName { get; init; } public string LastName { get; init; } } public sealed record Student : Person { public int ID { get; init; } } |
While this feature is beneficial, it can introduce complexities in certain situations when dealing with the with syntax and value-based equality.
Inheritance and value-based equality
Viewed from the perspective of the Person record, in the following code sample, both references hold identical values since they both have the same FirstName and LastName values.
|
1 2 3 4 5 6 7 8 9 10 |
Person person1 = new Student { FirstName = "Bill", LastName = "Gates", ID = 123 }; Person person2 = new Person { FirstName = "Bill", LastName = "Gates" }; Assert.IsFalse(person1 == person2); |
Fortunately, the compiler-generated code ensures that these two objects are treated as distinct. Upon revisiting the code produced by the compiler, you’ll notice that the implementation of the Equals() method relies on the virtual property protected virtual Type EqualityContract => typeof(Person);. This property is used to verify that the two compared objects share the same type.
Inheritance and the keyword ‘with’
In the code sample below it is not clear that person2 is a Student since it is inferred from a Person reference using the with syntax. As we saw in the Nondestructive mutation section, under the hood the generated virtual <Clone>$() method is called by the compiler. This virtual method is overridden by Student and its implementation calls the Student copy constructor:
|
1 2 3 4 5 6 7 |
Person person1 = new Student { FirstName = "Bill", LastName = "Gates", ID= 123 }; Person person2 = person1 with { LastName = "Clinton" }; Assert.IsTrue(person2 is Student); |
Declaring attributes
It is possible to declare attributes for any of the elements generated by the compiler based on the C# record definition. You can specify a target for any attribute you use on the positional properties of the record. In the following example, the System.Text.Json.Serialization.JsonPropertyNameAttribute is assigned to each property generated by the C# compiler, of the Person record. The property target is used to specify that the attribute applies to the compiler-generated property, while other targets such as field and param can be used to apply the attribute to the field or parameter, respectively.
|
1 2 3 |
public record Person( [property: JsonPropertyName("firstName")] string FirstName, [property: JsonPropertyName("lastName")] string LastName); |
Generic Record
Let’s note that a record can be a generic class or structure, which offers flexibility. However, developers should keep in mind that EqualityComparer<T>.Default is employed for each property typed with T in the generated code to perform state comparisons. This can potentially result in confusing behavior like in the following short program
|
1 2 3 4 5 6 7 8 9 |
var pair1 = new Pair<string>("1", "2"); var pair2 = new Pair<string>("1", "2"); Assert.IsTrue(pair1 == pair2); // equals with string var pair3 = new Pair<object>(new object(), new object()); var pair4 = new Pair<object>(new object(), new object()); Assert.IsFalse(pair3 == pair4); // different with object record Pair<T>(T Left, T Right); |
Practical Use Cases of C# Records
C# records are widely adopted in modern .NET development due to their immutability, value-based equality, and ability to reduce boilerplate code. Here are the most common use cases for C# records:
-
Simplifying Code
Records reduce repetitive code by automatically generating constructors, equality checks, and property handling. They are particularly useful for DTOs (Data Transfer Objects) and POCOs (Plain Old CLR Objects), keeping code clean and maintainable. -
Domain Models
In domain-driven design, records provide strongly typed, immutable models that reflect business logic. With value-based equality, formatting, and non-destructive mutation, they are ideal for representing domain entities. -
Configuration Settings
Records work well for application settings. Defining a record with properties for each configuration value ensures immutability while making it easy to pass settings across services and layers. -
Concurrent Access Scenarios
Since record classes are immutable by default, they help avoid concurrency issues that often occur with shared mutable state, reducing the need for explicit synchronization in multithreaded applications. -
API Response Models
Records are ideal for modeling RESTful API responses. They simplify deserialization, enforce strong typing, and ensure returned data remains immutable. -
Debugging and Logging
Records automatically generate readable string representations of their data, making them highly effective for logging and debugging. Printing a record provides a clear, formatted view of its properties.
Conclusion
C# records are a modern feature designed to handle immutable data efficiently and elegantly. They streamline code by reducing boilerplate, enhancing readability, and supporting thread-safe operations. Ideal for domain models, API responses, or configuration objects, records help keep applications maintainable and robust. Embracing records is essential for writing clean, efficient, and forward-compatible C# code as the language continues to evolve.




