NDepend Blog

Improve your .NET code quality with NDepend

C# Record Explained

August 26, 2025 7 minutes read

C#-record

A C# record is a lightweight, data-focused type mainly used for storing and representing values rather than behaviors. Introduced in C# 9, the record keyword makes it easy to define immutable, value-based classes for data modeling. With C# 10, the feature was expanded to include two new syntaxes: record class (functionally the same as a C# 9 record) and record struct, which allows developers to create structure-based records for more efficient data handling.

This tutorial dives into C# records’ syntax, applications and benefits.

Declaring a Record Type

With C# 9, Microsoft introduced the primary constructor syntax as a concise way to define a record’s constructor and its properties. This feature reduces boilerplate code by eliminating the need for manually declaring constructor parameters and property assignments:

The Primary Constructor syntax is optional and a C# record can be declared without it:

The Primary Constructor syntax also applies to record struct:

Note: In C# 12, the primary constructor syntax is now available for both classes and structs, not just records. However, in this non-record context, the compiler generates private fields instead of the public properties created when using records. This difference can be confusing at first, and the behavior is explained in detail here: C#12 class and struct Primary Constructors

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:

C#-immutable-record

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 two C# record instances are equal if their data matches. In contrast, classes use reference equality, comparing object references instead of values. Here’s an example of record equality in action:

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.

Non-destructive record mutation with the keyword ‘with’

The code above demonstrates that modifying a string object leads to a new string object being created to hold the modified state. This behavior is named non-destructive mutation because the original string object doesn’t get modified. Non-destructive mutation is available for C# record through the keyword with:

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.

C#-with-syntax-with-clone-method

Caution: Non-destructive string mutations create many objects in memory, increasing GC pressure and hurting performance. The class StringBuilder was designed to solve this. Likewise, non-destructive record mutations can also impact performance.

Declaring mutable record class

Let’s mention that it is possible to declare mutable record classes when not relying on the Primary Constructor syntax:

Caution: Developers expect records to be immutable. As a consequence, mutable records can lead to confusion and error-prone code.

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:

The same string representation is available at debug time:

C#-record-formatting

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:

To get an immutable record struct the keyword readonly must be used in readonly record struct:

C#-immutable-record-struct

Another record struct specificity is that a default parameterless constructor is provided: it sets each field to its default value:

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:

Notice that deconstruction makes records work seamlessly with C# Pattern Matching:

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.

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.

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:

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.

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

Practical Use Cases of C# Records

C# records are widely used in modern .NET development thanks to their immutability, value-based equality, and reduced boilerplate. Below are the most common C# record use cases:

  1. Simplifying Code
    Records eliminate repetitive code by auto-generating constructors, equality checks, and property handling. They are especially useful for DTOs (Data Transfer Objects) and POCOs (Plain Old CLR Objects), keeping codebases clean and maintainable.

  2. Domain Models
    In domain-driven design, C# records provide strongly typed, immutable models that align with business logic. They support value-based equality, formatting, and non-destructive mutation, making them ideal for representing domain entities.

  3. Configuration Settings
    Records are well-suited for application settings. By defining a record with properties for each configuration value, you ensure immutability while making it easy to pass settings across services and layers.

  4. Concurrent Access Scenarios
    Because C# record classes are immutable by default, they avoid concurrency issues that often arise with shared mutable state. This removes the need for explicit synchronization in many multithreaded scenarios.

  5. API Response Models
    When working with RESTful APIs, records are perfect for modeling responses. They simplify deserialization, provide strong typing, and ensure the returned data remains immutable.

  6. Debugging and Logging
    Records automatically generate a clear, concise string representation of their data, making them very effective for logging and debugging. Printing a record gives a readable, formatted view of its properties.

Conclusion

C# records are a powerful feature for modern C# development, providing a clean and efficient way to work with immutable data. They simplify code, improve readability, ensure thread safety, and reduce boilerplate compared to traditional classes. Whether you’re building domain models, API response types, or configuration objects, records make your applications more maintainable. As C# evolves, mastering features like records is key to writing clean, efficient, and future-ready code.

 

This article is brought to you by the team behind NDepend — a proven .NET static analysis tool for improving code maintainability, security, and overall quality. Whether you’re modernizing a legacy .NET application or starting fresh in C#, get started with your free full-featured trial today!

Leave a Reply

Your email address will not be published. Required fields are marked *