Skip to content

Records, equality & immutability

How records generate value equality, non-destructive copies, and deconstruction, and when a plain class or struct is the better choice.

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 ​

csharp
public record Person(string FirstName, string LastName);

This single line produces:

Generated memberBehaviour
ConstructorPositional parameters assign the properties
FirstName, LastNameinit-only auto-properties
Equals(Person), Equals(object)Member-wise value equality
GetHashCodeCombines all members, so equal records hash equally
operator ==, !=Value equality, not reference identity
ToStringPerson { FirstName = Ada, LastName = Lovelace }
DeconstructOut parameters for each positional member
protected copy constructorUsed by with to clone

Everything above is verified by this output:

csharp
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:

csharp
// 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; // True

This 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:

csharp
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:

csharp
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 reference

If 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:

csharp
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 reason

Two 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.

recordrecord structclassstruct
EqualityValue, generatedValue, generatedReference by defaultValue, reflection by default
== operatorGeneratedGeneratedReferenceNot generated
withYesYesNoYes from C# 10
Heap allocationYesNoYesNo
Can be nullYesNo (unless ?)YesNo (unless ?)
InheritanceRecords onlyNoneYesNone

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:

csharp
// 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 ToString only 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 == calls Equals, so overriding Equals changes == too; if you override Equals you must also override GetHashCode so that equal records hash equally.
  • Prefer readonly record struct for hot paths. A positional record struct is mutable by default; adding readonly makes every member readonly and avoids defensive copies.
  • with on a class is not possible. If you need copy-on-write semantics for a class, add a copy constructor or a Clone method by hand.

More information ​

Powered by Intracia