The value types post established that two Book objects with identical contents are not equal, because classes compare identity. The strings post showed how much you gain when a type is immutable. Records are C#‘s answer to both at once: a class that compares by contents and resists being changed, in one line of code.
The boilerplate records delete
Suppose you want a type that represents a book value: two of them with the same fields should be equal, and neither should change after construction. Written by hand, correctly, in pre-record C#:
public sealed class Book : IEquatable<Book>
{
public int Id { get; }
public string Title { get; }
public int Price { get; }
public Book(int id, string title, int price)
{
Id = id;
Title = title;
Price = price;
}
public bool Equals(Book? other) =>
other is not null && Id == other.Id && Title == other.Title && Price == other.Price;
public override bool Equals(object? obj) => Equals(obj as Book);
public override int GetHashCode() => HashCode.Combine(Id, Title, Price);
public static bool operator ==(Book? a, Book? b) => a is null ? b is null : a.Equals(b);
public static bool operator !=(Book? a, Book? b) => !(a == b);
public override string ToString() => $"Book {{ Id = {Id}, Title = {Title}, Price = {Price} }}";
public Book WithPrice(int price) => new Book(Id, Title, price);
}
Forty lines to express three properties. Worse, every one of those methods must be updated when you add a field, and forgetting to add it to GetHashCode produces a bug that only shows up when the object is used as a dictionary key.
Here is the same type as a record:
public record Book(int Id, string Title, int Price);
One line. It generates the constructor, the read-only properties, Equals, GetHashCode, ==, !=, ToString, IEquatable<Book>, a deconstructor, and a copy mechanism. Add a property and every one of them updates itself.
What you actually get
var a = new Book(1, "1984", 15);
var b = new Book(1, "1984", 15);
Console.WriteLine(a == b); // True, value equality
Console.WriteLine(ReferenceEquals(a, b)); // False, still two objects
Console.WriteLine(a); // Book { Id = 1, Title = 1984, Price = 15 }
var (id, title, price) = a; // deconstruction
var discounted = a with { Price = 10 }; // copy with one change
Console.WriteLine(discounted); // Book { Id = 1, Title = 1984, Price = 10 }
Console.WriteLine(a.Price); // 15, the original is untouched
Four features worth calling out.
Value equality. == compares the type and every field. This is the headline feature and it is why records are the default choice for DTOs, API models, query results, domain values and messages, types whose whole purpose is to carry data.
A useful ToString. Book { Id = 1, Title = 1984, Price = 15 } instead of MyApp.Models.Book. Logs and debugger windows become readable for free, which is a bigger day-to-day win than it sounds.
with expressions. Non-destructive mutation: copy the object, change the named properties, leave the rest. This is how you work with immutable data without writing a WithX method per property.
Positional syntax. record Book(int Id, string Title, int Price) declares the primary constructor and the properties together. The properties it creates are init-only.
init, and what immutability actually means here
public record Book
{
public int Id { get; init; }
public string Title { get; init; }
public int Price { get; init; }
}
var a = new Book { Id = 1, Title = "1984", Price = 15 }; // object initialiser works
a.Price = 10; // compile error
init is a setter that only works during construction, inside a constructor, an object initialiser, or a with expression. It gives you the readability of object-initialiser syntax with the safety of get-only properties. It is not record-specific; you can use init on any class.
The important caveat: records are shallowly immutable. If a record holds a mutable object, that object is still mutable:
public record Order(int Id, List<Book> Items);
var order = new Order(1, new List<Book>());
order.Items.Add(new Book(1, "1984", 15)); // perfectly legal, the list is not frozen
And this leaks into equality: the generated Equals compares each field with its equality. List<T> uses reference equality, so two orders with identical contents in different list instances compare as not equal. For collection properties, use ImmutableArray<T> or IReadOnlyList<T> with a value-comparing wrapper, or write Equals yourself. This is the number one surprise people hit with records.
record struct
C# 10 added the struct flavour:
public readonly record struct Point(int X, int Y);
That combines everything from the value types post with everything here: stack-friendly, no heap allocation, copied on assignment, plus generated equality and ToString. Note that a plain struct already had value equality, but the compiler-generated version uses reflection and is slow; record struct generates a fast field-by-field comparison instead.
Prefer readonly record struct for small immutable values, money, coordinates, identifiers, date ranges. Use a plain record (which is a class) for everything larger.
Inheritance, and the equality rule that catches people
Records can inherit from other records, and the generated equality accounts for it:
public record Product(int Id, string Title);
public record Book(int Id, string Title, string Author) : Product(Id, Title);
Product p = new Book(1, "1984", "Orwell");
Product q = new Product(1, "1984");
Console.WriteLine(p == q); // False
Even though the shared fields match, they are not equal, because the generated equality compares a hidden EqualityContract, the runtime type, first. That is the correct behaviour and it is one of the few places records do something a hand-written implementation usually gets wrong.
A record cannot inherit from a plain class, and a class cannot inherit from a record.
When not to use a record
Records are not “the new class”. Choose them by intent:
Use a record when the type is data. DTOs, API request and response models, configuration, domain value objects, events and messages, results of queries. Anything where equality means “same contents”.
Use a class when the type is a thing with identity and behaviour. An Order in a database has an identity: two orders with the same fields are still two different orders, and one of them can be cancelled. Entities, services, controllers, repositories and anything EF Core tracks should be classes. EF Core in particular tracks entities by key identity, so value equality actively fights the change tracker, and mutating navigation properties on a record undermines the whole point of choosing one.
Do not use a record just to skip typing a constructor. Since C# 12, plain classes have primary constructors too:
public class BookService(IBookRepository repository) // class primary constructor, not a record
{
public Book? Find(int id) => repository.Get(id);
}
That is the right tool for a service with dependencies. A record there would generate equality members that make no sense for a service.
One more practical note: records are still reference types. record Book is a class. It can be null, it is allocated on the heap, and assignment copies a reference. Only record struct changes that.
The same idea inside Java, Kotlin and Scala
Every modern language reached this conclusion at roughly the same time: data classes deserve their own declaration.
Java (Netflix, everywhere). Records landed in Java 16 with almost the same syntax and the same generated members:
public record Book(int id, String title, int price) { }
Java records are final, shallowly immutable, and generate equals, hashCode and toString. Java has no with expression yet, so copying still means calling the constructor with everything.
Kotlin (Google, Android). data class arrived years earlier and set the template, including the copy operation C#‘s with mirrors:
data class Book(val id: Int, val title: String, val price: Int)
val discounted = book.copy(price = 10)
Scala. case class is the ancestor of all of these, value equality, pattern matching support, and a copy method, from 2004. C#‘s with and its pattern-matching integration are both visibly descended from it.
TypeScript (Meta). No records, but the same idiom appears as object spread, which is with by another name:
const discounted = { ...book, price: 10 };
React’s entire state model depends on it, for the reason the value types post gave: mutating shared objects breaks change detection.
F# and Rust. F# records and Rust structs with #[derive(PartialEq, Clone, Debug)] are the same idea again: declare the shape, let the compiler write the mechanics. Rust’s struct update syntax Book { price: 10, ..book } is with almost character for character.
Summary
- A record is a class (or with
record struct, a struct) that generates value equality,GetHashCode,ToString, deconstruction and a copy mechanism from its declared members. public record Book(int Id, string Title, int Price);replaces around forty lines of hand-written boilerplate, and stays correct when you add a member.==on records compares type and contents;ReferenceEqualsstill shows two distinct objects.initproperties are settable only during construction, in an object initialiser or in awithexpression.withproduces a copy with named properties changed, leaving the original untouched.- Records are shallowly immutable: a mutable field such as a
List<T>is still mutable, and it also breaks value equality because lists compare by reference. readonly record structis the right choice for small immutable values; a record class for larger data.- Record equality includes the runtime type, so a base record and a derived record are never equal.
- Use records for data, classes for things with identity and behaviour. EF Core entities and services should stay classes.
Interview questions and answers
1. What is a record in C#?
A reference type (or with record struct, a value type) declared with the record keyword, for which the compiler generates value-based Equals, GetHashCode, == and !=, a formatted ToString, a Deconstruct method, and a copy constructor supporting with expressions. It is intended for types whose purpose is to hold data.
2. What is the difference between a record and a class?
A record is a class, plus generated value equality and copying semantics, and its positional properties are init-only by default. A plain class uses reference equality unless you write your own, and is mutable unless you make it otherwise. The difference is intent: records model values, classes model things with identity and behaviour.
3. How does equality work for records?
The generated Equals first compares the EqualityContract, which is the runtime type, then compares every instance field using that field’s own equality. So two records of the same type with equal fields are equal, and a base record is never equal to a derived record even when their shared fields match.
4. Are records immutable?
Only shallowly, and only if you use the positional syntax or init accessors. The generated properties cannot be reassigned after construction, but an object referenced by a property (a List<T>, for instance) can still be mutated. You can also declare a record with normal set accessors, which makes it mutable.
5. What does a with expression do?
It creates a copy of the record by invoking the generated copy constructor, then applies the named property assignments to the new instance through their init accessors. The original is unchanged. It exists so immutable data can be “modified” without writing a bespoke method per property.
6. What is the difference between init and set?
set allows assignment at any time after construction. init allows assignment only during object construction (inside a constructor, an object initialiser, or a with expression) and is a compile error afterwards. init is available on ordinary classes and structs too, not just records.
7. What is a record struct?
A value type declared with record struct, which combines struct semantics (inline storage, copy on assignment, no heap allocation) with compiler-generated equality, ToString and deconstruction. Adding readonly makes it immutable. It is also faster than the default struct equality, which relies on reflection.
8. Can records participate in inheritance?
A record can inherit from another record, and the derived record’s generated members include the base members. A record cannot inherit from a plain class, and a class cannot inherit from a record, because the generated equality contract requires the whole chain to be records.
9. When should you not use a record?
For entities with identity, where two objects with equal fields are still different things, EF Core entities in particular, since value equality conflicts with key-based change tracking. Also for services and controllers, which have behaviour and dependencies rather than data, and for any type that must be mutable by design.
10. Do records solve the equality problem for collections inside them?
No. Generated equality delegates to each field’s own Equals, and List<T> and arrays use reference equality, so two records holding equal-but-separate lists compare as unequal. Use an immutable collection with structural equality, or override Equals and GetHashCode for those members yourself.