Records C# 9.0 are the C# answer to a data-centric, immutable type. Writing that type by hand means implementing Equals, GetHashCode, ==, !=, ToString, a copy method with with, and deconstruction. A record declaration asks the compiler to write all of it.
The feature arrived in C# 9.0 and was extended in C# 10 with record structs and with expressions for structs. Knowing which parts are generated, and which are not, is what stops records from surprising you.
What the compiler generates
public record Person(string FirstName, string LastName);This single line produces:
| Generated member | Behaviour |
|---|---|
| Constructor | Positional parameters assign the properties |
FirstName, LastName | init-only auto-properties |
Equals(Person), Equals(object) | Member-wise value equality |
GetHashCode | Combines all members, so equal records hash equally |
operator ==, != | Value equality, not reference identity |
ToString | Person { FirstName = Ada, LastName = Lovelace } |
Deconstruct | Out parameters for each positional member |
protected copy constructor | Used by with to clone |
Everything above is verified by this output:
var a = new Person("Ada", "Lovelace");
var b = new Person("Ada", "Lovelace");
a == b; // True, value equality
a.GetHashCode() == b.GetHashCode(); // True
a.ToString(); // "Person { FirstName = Ada, LastName = Lovelace }"
var (first, last) = a; // Deconstruct gives "Ada" and "Lovelace"Value equality is the whole point
A plain class with the same properties would give a == b as False, because it would use reference identity. A plain struct would give a.Equals(b) as True via reflection-based member comparison, but a == b would not even compile unless you declared the operator yourself.
Records make value equality the default and generate the fast, non-reflective implementation:
// Plain class: reference equality
var c1 = new PersonClass("Ada", "Lovelace");
var c2 = new PersonClass("Ada", "Lovelace");
c1 == c2; // False - two different objects
// Record: value equality
var r1 = new Person("Ada", "Lovelace");
var r2 = new Person("Ada", "Lovelace");
r1 == r2; // TrueThis is what makes records safe as dictionary keys and as LINQ grouping keys, and is the main reason to reach for one.
Non-destructive mutation with with
with copies the existing instance and applies the listed changes, leaving the original untouched:
var original = new Person("Ada", "Lovelace");
var married = original with { LastName = "Byron" };
original.LastName; // "Lovelace" - unchanged
married.LastName; // "Byron"The copy is shallow. For a record holding a list or another reference type, the copy shares that reference, so a mutable collection inside a record is still mutable:
public record Order(string Id, List<string> Lines);
var a = new Order("A1", new List<string> { "book" });
var b = a with { Id = "A2" };
b.Lines.Add("pen"); // a.Lines also has "pen" - shared referenceIf a record must be deeply immutable, use immutable collections or make the members immutable types themselves. This is the single most common misconception about records.
Inheritance and equality
A record can inherit from another record, and equality requires the runtime types to match:
public record Base(int Id);
public record Derived(int Id, int Extra) : Base(Id);
Base derived = new Derived(1, 2);
Base plain = new Base(1);
derived.Equals(plain); // False - different runtime types
derived == plain; // False, same reasonTwo records are only equal if they are the same type and all members are equal. A base-typed variable holding a derived record will not equal a base record with matching base members.
A record cannot inherit from a class, and only a record may inherit from a record. A record may implement interfaces.
Records vs classes vs structs
Use a record when the type is defined by its data, you want value equality, and you want an immutable-by-default shape. Use a class when the type has identity that outlives its values, needs mutable state, or must inherit from a non-record base.
record | record struct | class | struct | |
|---|---|---|---|---|
| Equality | Value, generated | Value, generated | Reference by default | Value, reflection by default |
== operator | Generated | Generated | Reference | Not generated |
with | Yes | Yes | No | Yes from C# 10 |
| Heap allocation | Yes | No | Yes | No |
| Can be null | Yes | No (unless ?) | Yes | No (unless ?) |
| Inheritance | Records only | None | Yes | None |
record struct and readonly record struct arrived in C# 10. Use readonly record struct for small data types that should not allocate, such as coordinates or money amounts. See record structs C# 10.0.
Positional vs nominal declaration
The positional syntax is convenient but not mandatory. Declaring properties explicitly gives control over accessibility, validation and defaults:
// Positional
public record Person(string FirstName, string LastName);
// Nominal - same generated members, explicit properties
public record Person
{
public required string FirstName { get; init; }
public required string LastName { get; init; }
}The second form needs required members C# 11.0 to keep the same guarantee that the caller sets both values. You can also mix the two, using positional parameters and adding further members in the body.
init-only setters came from init-only setters C# 9.0, and are what make a positional record immutable after construction.
Tips
- Override
ToStringonly when needed. A record's default includes the type name and every member, which is often too verbose or leaks sensitive values into logs. - Do not use a record for an entity. An entity with a database identity is defined by its key, not its field values; two rows with identical values are still different entities, which is the opposite of what a record gives you.
- Custom equality requires care. The compiler-generated
operator ==callsEquals, so overridingEqualschanges==too; if you overrideEqualsyou must also overrideGetHashCodeso that equal records hash equally. - Prefer
readonly record structfor hot paths. A positionalrecord structis mutable by default; addingreadonlymakes every memberreadonlyand avoids defensive copies. withon a class is not possible. If you need copy-on-write semantics for a class, add a copy constructor or aClonemethod by hand.