What are record types in C# / .NET?

As 2025 has rolled around, I've actually been writing some C# code for the first time in 4 years - and it turns out there's been quite a lot of developments!
Here I'm going to focus on an addition from C#9 - Record Types.
What is a Record Type?
Records are a special type of class (or struct, see later), primarily meant for representing data. This could represent rows, or "records", in a database, JSON with a well-defined structure, rows in a Parquet file, messages sent within a system, etc. They were designed to make writing immutable types in C# easier - something which has long been a pain point in .NET.
Let's start with an example:
public record Car(string Colour, bool IsElectric, bool IsUnreasonablyExpensive);
I think, between those three properties, we've pretty much covered all the important aspects of a car... We can now construct records for a collection of cars, e.g.
var porsche = new Car("Silver", true, true);
var bmw = new Car("Black", false, true);
var corsa = new Car("Green", false, false);
You can also create nested records.
Let's imagine that the Car
s in a particular garage could be represented by a Garage
record:
public record Garage(Car Car1, Car Car2);
var garage = new Garage(porsche, bmw);
Records then make it easier to work with that data. They have the following features:
- Simple initialisation
- Usually immutable
- Make copy-initialisation easy
- Their own implementation of equality
- Updated
ToString
behaviour - Can be either classes or structs
A crucial thing to remember is that everything you can achieve with a record could also be done with an ordinary class - but records aim to make life easier when dealing with (often immutable) data types. And, because records are just classes, they are still able to inherit from interfaces, and use other records as base classes, meaning that you can build powerful systems that leverage inheritance and polymorphism.
If we define an interface, IVehicle
:
public interface IVehicle
{
string Colour { get; }
}
We can then make our Car
record inherit from that interface:
public record Car(string Colour, bool IsElectric, bool IsUnreasonablyExpensive)
: IVehicle;
And then use our record as we would with any other inheriting class:
void PrintVehicleColour(IVehicle vehicle)
{
Console.WriteLine(vehicle.Colour);
}
Car bmw = new("Black", false, true);
PrintVehicleColour(bmw);
To print out the Car
's colour (Black
)!
Though this is a very simple example, you can use this (as you can with any class) to build up complex systems of inheritance to represent powerful interconnected data models. For a great example of this, check out AIS.NET.Models. This is a wrapper around the high-performance, but difficult to understand, AIS.NET, which uses record inheritance to represent complex geospatial shipping data.
So, now let's dig into some of these record-specific features in a bit more detail!
Simple Initialisation
As we saw in the example above, record types can be initialised using the in-line primary constructor syntax. If we were to write the equivalent class construction we would need to write:
public class CarClass(string Colour, bool IsElectric, bool IsUnreasonablyExpensive)
{
public string Colour { get; } = Colour;
public bool IsElectric { get; } = IsElectric;
public bool IsUnreasonablyExpensive { get; } = IsUnreasonablyExpensive;
}
This still uses the inline primary constructor (which we can use with any class as of C#12), but as you can see we still need to declare and set the equivalent properties. This doesn't feel like a huge amount of extra effort with only these three properties. But, as objects grow so does the overhead.
Immutability
In the above example, the record that is initialised will be immutable. This means that the compiler will show an error if you try to update any of the record's properties once it's been initialised.
I.e.
Car porsche = new("Silver", true, true);
porsche.IsElectric = false;
would produce an error. This immutability is a central part of how records have been designed.
Copy Initialisation
Though immutable objects provide a lot of benefits, working with them in C# has always been a bit fiddly. Firstly, there is no easy way to produce a copy of an instance of a class, as doing:
CarClass bmw = new("Black", false, true);
CarClass theSameBmw = bmw;
will just copy the reference, so the new variable will still be pointing to the same bit of memory. As we are talking about immutable objects, it may be fine (and if anything, advisable!) to just point all equal objects to the same bit of memory. However as things change in the system, you often need to create new objects that represent the updated state.
But if you wanted to create a new version of this class, with just one of its properties updated, you would have to individually copy across the values of every other property.
For example, when you were painting your bmw red, you would need to do this:
CarClass bmw = new("Black", false, true);
CarClass paintedBmw = new("Red", bmw.IsElectric, bmw.IsUnreasonablyExpensive);
Again, you can imagine that as the number of properties increases, this quickly becomes unwieldy.
Luckily, records have solved this problem!
To instantiate a new record based on another, with one property updated, you can use the syntax:
Car bmw = new("Black", false, true);
Car paintedBmw = bmw with
{
Colour = "Red"
};
This will create a shallow copy of the original record, with all references copied over. Again, remember that we are assuming we are working with immutable objects, so a shallow copy is sufficient. It is possible to create mutable records (as we'll see later) - at which point you would need to bear this behaviour in mind.
Equality Operator
Another important differentiation between records and other classes is that records have their own equality implementation. Usually, when you compare two objects of a given class, the ==
operator just checks whether the references are the same. I.e.
CarClass bmw = new("Black", false, true);
CarClass mercedes = new("Black", false, true);
Console.WriteLine(bmw == mercedes);
would produce False
, because the two objects are pointing to different bits of memory, that just happen to hold the same values.
However, if you're like me and think all expensive (manual) cars of the same colour are essentially just the same thing, you might want to use a record type instead.
For record types, instead of comparing what memory the object is referencing, the ==
operator instead compares the values of all the properties. So, using our record type:
Car bmw = new("Black", false, true);
Car mercedes = new("Black", false, true);
Console.WriteLine(bmw == mercedes);
returns True
.
This equality checking also works with nested records. For example, if you construct one Garage
which contains a porsche and a bmw, and one that contains a porsche and a mercedes...
Car porsche = new("Silver", true, true);
Car bmw = new("Black", false, true);
Car mercedes = new("Black", false, true);
Garage garage = new(porsche, bmw);
Garage garage2 = new(porsche, mercedes);
Console.WriteLine(garage == garage2);
returns True
(with which I may have stretched my metaphor to its breaking point).
The C# compiler doesn't do anything special to make this happen - This is an automatic upshot of the basic fact that record Equals
just compares each of the properties in turn (by calling Equals
on each of them).
Note: if, instead of two individual cars, we defined a Carpark
that contained a array of cars:
public record Carpark(Car[] Cars);
Then doing:
Carpark carpark = new([porsche, bmw]);
Carpark carpark2 = new([porsche, mercedes]);
Console.WriteLine(carpark == carpark2);
and even
Carpark carpark = new([porsche, bmw]);
Carpark carpark2 = new([porsche, bmw]);
Console.WriteLine(carpark == carpark2);
Would return False
as an Array
is a normal reference type, and therefore the equality operator would use reference comparison, and the each of the nested arrays wouldn't be seen as equal.
Updated ToString
Behaviour
Another difference between records and other classes is the behaviour of the ToString
method.
If you were to write:
CarClass bmw = new("Black", false, true);
Console.WriteLine(bmw.ToString());
It would just print out the name of the class, and the namespace in which it resides: RecordTypesDemo.CarClass
.
However, if we were to use our record type, it instead prints out the values of all the properties:
Car bmw = new("Black", false, true);
Console.WriteLine(bmw.ToString());
Prints out: Car { Colour = Black, IsElectric = False, IsUnreasonablyExpensive = True }
.
Similarly, with nested record types:
Car bmw = new("Black", false, true);
Car porsche = new("Silver", true, true);
Garage garage = new(porsche, bmw);
Console.WriteLine(garage.ToString());
Produces: Garage { Car1 = Car { Colour = Silver, IsElectric = True, IsUnreasonablyExpensive = True }, Car2 = Car { Colour = Black, IsElectric = False, IsUnreasonablyExpensive = True } }
.
Note: again, if we instead used our Carpark
record:
Carpark carpark = new([porsche, bmw]);
Console.WriteLine(carpark.ToString());
Then it produces: Carpark { Cars = RecordTypesDemo.Car[] }
, as the Array
is an ordinary class, and therefore it uses the standard behaviour.
Classes Versus Structs
Up until this point, we have been talking about class-type records, however records can actually be either class or struct types. The differences between classes and structs in C# could probably be the subject of an entire post. But, put simply, classes are reference types, whereas structs are value types.
There are implications in terms of memory usage, recommended size of objects, etc.
But, in practical terms for our record types, the crucial difference is that structs are copied on assignment, where classes are not. I.e. doing:
CarStruct bmw = new("Black", false, true);
CarStruct copyOfBmw = bmw;
Will create a copy of the BMW. This means that record structs do not provide an additional advantage over normal structs in terms of the ability to create an exact copy of an object. But again, when records are immutable, why would we ever need to do that?
However, if you need to update a single property, with record structs you can still use the with
syntax to do so. Additionally, you cannot check regular structs for equality using the ==
operator (unless you explicitly override it). Using a record struct, however, the equality checking works just as it does for classes. And finally, the advantages around immutability and simplification of initialisation are also gained, just like they are for classes.
You define a record struct as follows:
public record struct CarRecordStruct(string Colour, bool IsElectric, bool IsUnreasonablyExpensive);
However, a difference between records and record structs is that where records are immutable by default, record structs are not. So the following code would not produce an error:
CarRecordStruct bmw = new("Black", false, true);
bmw.Colour = "Red";
If you want your record structs to be immutable, you need to add the readonly
keyword:
public readonly record struct CarRecordStruct(string Colour, bool IsElectric, bool IsUnreasonablyExpensive);
Which will then ensure that the properties cannot be updated.
Records With Optional Properties
Sometimes you need to represent objects or data that may or may not define certain properties (perhaps we don't know how expensive some cars are!). This is also possible using records:
public record Car(string Colour, bool IsElectric)
{
public bool? IsUnreasonablyExpensive { get; init; }
}
Using the init
method means that the property can be only set on initialisation (so our records are still immutable), but it isn't required. We can then set this property for only some of our cars:
Or, equivalently you could write:
public record Car
{
public required string Colour { get; init; }
public required bool IsElectric { get; init; }
public bool? IsUnreasonablyExpensive { get; init; }
}
Using the required
keyword to mark the properties that are non-optional.
Car porsche = new("Black", true) { IsUnreasonablyExpensive = true };
Car bmw = new("Silver", false);
Car corsa = new("Silver", false) { IsUnreasonablyExpensive = false };
Mutable records
As I mentioned earlier, it is possible to create mutable records. However, a lot of care should be taken when doing so. This functionality was built with immutability at its centre, and therefore mutable records may end up behaving in ways you wouldn't expect. A good example of this being that any copies produced are shallow copies, meaning that any nested classes (records included) within your record, will just by copied by reference. If these nested objects are then mutated, all records with that reference would then be pointed at the updated value.
Sometimes it's not possible to work with entirely immutable objects. In this case, you might want to make it to some properties can be updated. This is still possible with records:
public record Car(string Colour, bool IsElectric, bool IsUnreasonablyExpensive)
{
public string Colour { get; set; } = Colour;
}
Notice that here we are using the set
method, instead of init
.
The following code would then not produce an error:
Car porsche = new("Black", true, true);
porsche.Colour = "Red";
And, though the record is no longer immutable, we are still able to take advantage of the equality comparisons, and ToString
implementation, that records provide.
In Conclusion
Overall, record types allow us to easily work with immutable data. They provide us with convenient ways to copy (and update), compare, and print out data objects. Though all of the functionality described here would also be possible with ordinary classes, records provide us with a concise way to perform these common operations.