C# · lesson 12 of 26
Records in C#
Positional records, value-based equality, non-destructive mutation with with, and record struct in C#.
By SkillByExample · Published
Positional Records
A positional record declares its properties directly in the type’s parameter list. The compiler generates a constructor, init-only properties, a readable ToString, and value-based Equals/GetHashCode — all from that one line. The with expression below produces a modified copy without touching the original, and a record can inherit from another record the same way a class inherits from another class.
var p1 = new Point(1.5, 2.5);
var p2 = new Point(1.5, 2.5);
var p3 = p1 with { Y = 9.9 };
Console.WriteLine(p1);
Console.WriteLine(p1 == p2);
Console.WriteLine(ReferenceEquals(p1, p2));
Console.WriteLine(p3);
Console.WriteLine(p1);
var p3d = new Point3D(1, 2, 3);
Console.WriteLine(p3d);
Console.WriteLine(p3d is Point);
public record Point(double X, double Y);
public record Point3D(double X, double Y, double Z) : Point(X, Y);
$ dotnet run
Point { X = 1.5, Y = 2.5 }
True
False
Point { X = 1.5, Y = 9.9 }
Point { X = 1.5, Y = 2.5 }
Point3D { X = 1, Y = 2, Z = 3 }
True
p1 == p2 is True even though ReferenceEquals says they are two different objects — records compare by value, field by field, not by identity. p3 is a separate object from p1 with only Y changed; p1 prints unchanged afterward, which is the point of with. Point3D inherits X and Y from Point and adds Z, and p3d is Point confirms the inheritance relationship still holds.
Records vs Classes — Equality and Hashing
The generated members are the entire reason to reach for a record instead of a plain class. Two classes with identical property values are still different objects to == and Equals unless you hand-write equality members yourself; two records with identical values are equal automatically.
var c1 = new PersonClass { Name = "Ana", Age = 30 };
var c2 = new PersonClass { Name = "Ana", Age = 30 };
var r1 = new PersonRecord("Ana", 30);
var r2 = new PersonRecord("Ana", 30);
Console.WriteLine(c1 == c2);
Console.WriteLine(r1 == r2);
Console.WriteLine(c1.Equals(c2));
Console.WriteLine(r1.Equals(r2));
Console.WriteLine(c1.GetHashCode() == c2.GetHashCode());
Console.WriteLine(r1.GetHashCode() == r2.GetHashCode());
public class PersonClass
{
public string Name { get; init; } = "";
public int Age { get; init; }
}
public record PersonRecord(string Name, int Age);
$ dotnet run
False
True
False
True
False
True
Every comparison on PersonClass falls back to object’s reference equality — c1 and c2 are different objects, full stop, regardless of their field values. PersonRecord overrides all three: ==, Equals, and GetHashCode all agree the two instances are interchangeable, because records generate them from the declared properties. This is exactly the behavior you want for a Dictionary<PersonRecord, ...> key or a duplicate check on a list of DTOs — with a plain class, you’d need to hand-write Equals/GetHashCode (or use a tuple) to get the same result.
record struct — Value-Type Records
record struct applies the same generated Equals/GetHashCode/ToString/with treatment to a value type instead of a reference type. Unlike a positional record class, a record struct’s positional properties are mutable by default — add readonly before record to lock them down, the same way you’d use readonly on any other struct.
var a = new Point2D(1, 2);
var b = a;
b.X = 99;
Console.WriteLine(a);
Console.WriteLine(b);
Console.WriteLine(a == b);
var ip = new ImmutablePoint(3, 4);
Console.WriteLine(ip);
public record struct Point2D(double X, double Y);
public readonly record struct ImmutablePoint(double X, double Y);
$ dotnet run
Point2D { X = 1, Y = 2 }
Point2D { X = 99, Y = 2 }
False
ImmutablePoint { X = 3, Y = 4 }
var b = a; copies Point2D by value — a and b are independent, so mutating b.X never touches a, and a == b is False once their values diverge. That mutability is the tradeoff for choosing record struct: a record class would have made X init-only automatically, but a plain record struct leaves it settable unless you add readonly, as ImmutablePoint does.
Practice
- Three-property equality. Add a
Departmentproperty toPersonRecord(a third positional parameter) and confirm that two records with the sameName/Agebut differentDepartmentare no longer equal.
Solution
var p = new PersonRecord("Ana", 30, "Engineering");
var p2 = new PersonRecord("Ana", 30, "Marketing");
Console.WriteLine(p == p2);
public record PersonRecord(string Name, int Age, string Department);
$ dotnet run
False
Equality is generated from every positional property, so adding Department means all three must match now — Engineering vs Marketing is enough to make the records unequal even with Name and Age identical.
- Non-destructive raise. Use
withto give aPersonRecorda raise (incrementAgeby 1) into a new variable, then print both records to confirm the original didn’t change.
Solution
var p = new PersonRecord("Ana", 30, "Engineering");
var raise = p with { Age = 31 };
Console.WriteLine(p);
Console.WriteLine(raise);
public record PersonRecord(string Name, int Age, string Department);
$ dotnet run
PersonRecord { Name = Ana, Age = 30, Department = Engineering }
PersonRecord { Name = Ana, Age = 31, Department = Engineering }
with never mutates the record it’s called on — p still reports Age = 30 after the call. raise is a distinct object carrying the updated value.
- Read the compiler error. Try to mutate a
readonly record struct’s property directly (not throughwith) and read the resulting error.
Solution
var b = new Point2DReadonly(1, 2);
b.X = 99;
public readonly record struct Point2DReadonly(double X, double Y);
$ dotnet build
error CS8852: Init-only property or indexer 'Point2DReadonly.X' can only be
assigned in an object initializer, or on 'this' or 'base' in an instance
constructor or an 'init' accessor.
readonly on a record struct makes every positional property init-only, the same protection a record class gets by default — the compiler rejects the assignment before the program ever runs.
- Deconstruction. Deconstruct a
PersonRecordinto three separate local variables using pattern-matching deconstruction, and print them.
Solution
var p = new PersonRecord("Ana", 30, "Engineering");
var (name, age, dept) = p;
Console.WriteLine($"{name} is {age}, works in {dept}");
public record PersonRecord(string Name, int Age, string Department);
$ dotnet run
Ana is 30, works in Engineering
Positional records generate a Deconstruct method automatically, in the same order as the positional parameters — no extra code needed to unpack one into individual variables.