Records, or data classes, were introduced with C# 9. They have a specific and concise syntax along with some strong constraints. Despite being released in late 2020, I feel like they are not very popular : I still encounter DTOs and POCOs written with classes and public getters/setters. So why use records in C# in 2025 ?

Concise syntax
One of the first things we notice when dealing with records is their extremely simple syntax :
public record Price(decimal Value, Currency Currency);
In a single line, we have one constructor and two get-only public properties ! This is equivalent to :
public class Price(decimal value, Currency currency)
{
public decimal Value { get; init; } = value;
public Currency Currency { get; init; } = currency;
}
Except… not exactly. Records have dedicated behaviours that make them even more useful.
Immutability
Records are immutable. It means that once an instance of a record is created, it cannot be modified (well, not 100% but we’ll get back to that). For example, this code will not build :
var price = new Price(15m, Currencies.EUR);
price.Value = 12m;
Each property cannot be set, it’s get-only. Immutability is great because it avoids side effects. However, let’s say you really want to copy an object’s properties except for one or two properties. Well, the with
keyword has got you covered :
var price = new Price(15m, Currencies.EUR);
var newPrice = price with { Value = 12m };
And now you have a second instance that has the same properties as the first one except the one(s) you specified in the with
clause ! The first instance is not modified, which avoids side-effects, but you still get an instance that does what you want. This is called a non-destructive mutation.
Value objects
Records are reference types (only record structs are value types) but they are value objects. That means that if 2 different instances have the same properties, then they will be considered equal. Consider this test :
[Test]
public void TestMethod()
{
var firstPrice = new Price(15m, Currencies.EUR);
var secondPrice = new Price(15m, Currencies.EUR);
Check.That(firstPrice).IsEqualTo(secondPrice);
}
This test will actually pass. To achieve the same thing with a class, we must override the Equals() and GetHashCode() methods. This results in boilerplate code that is mostly useless 90% of the time. But not with records !
Built-in deconstruction
But wait, there’s more ! What if we’re using records in our code but some third-party library uses good old native types ? With a class, we would use each property individually and that’s totally fine (unless the third-party modifies the data, which would have some side effect… see where I am going ?). With records we can directly deconstruct them :
var price = new Price(15m, Currencies.EUR);
var (value, currency) = price;
And we’re good to go.
Oh one more thing…
As if it wasn’t enough, C# devs were kind enough to add a built-in ToString()
override :
var price = new Price(15m, Currencies.EUR);
Console.WriteLine(price);
In the output console :
Price { Value = 15, Currency = Currency { IsoCode = EUR, Name = Euro } }
When debugging :

Extremely useful at no cost.
Still a fan of classes ?
So the question asked in the title should be more “why NOT use records in C# ?” Sure, I’m still using classes when there is some logic and when I do not want to compare objects. But I am a huge proponent of records when it comes to DTOs or business model. That’s what they are made for. So how come there are still DTOs as classes with public getters/and setters and default constructor ?
Legacy code does not explain everything. Records were introduced over 4 years ago, it’s time to evolve !
By the way, here is the code of a class with an equivalent behaviour as a simple record declaration :
public class PriceClass(decimal Value, Currency Currency)
{
public decimal Value { get; init; } = Value;
public Currency Currency { get; init; } = Currency;
protected bool Equals(PriceClass other)
{
return Value == other.Value && Currency.Equals(other.Currency);
}
public override bool Equals(object? obj)
{
if (obj is null) return false;
if (ReferenceEquals(this, obj)) return true;
if (obj.GetType() != GetType()) return false;
return Equals((PriceClass)obj);
}
public override int GetHashCode()
{
return HashCode.Combine(Value, Currency);
}
public void Deconstruct(out decimal value, out Currency currency)
{
value = Value;
currency = Currency;
}
public override string ToString()
{
var stringBuilder = new StringBuilder();
stringBuilder.Append(nameof(PriceClass));
stringBuilder.Append(" { ");
stringBuilder.Append($"Value = {Value}, Currency = {Currency}");
stringBuilder.Append(" }");
return stringBuilder.ToString();
}
}
Software developer since 2000, I try to make the right things right. I usually work with .Net (C#) and Azure, mostly because the ecosystem is really friendly and helps me focus on the right things.
I try to avoid unnecessary (a.k.a. accidental) complexity and in general everything that gets in the way of solving the essential complexity induced by the business needs.
This is why I favor a test-first approach to focus on the problem space and ask questions to business experts before even coding the feature.