Skip to main content
C# · lesson 12 of 26
C# intermediate 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

  1. Three-property equality. Add a Department property to PersonRecord (a third positional parameter) and confirm that two records with the same Name/Age but different Department are 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.

  1. Non-destructive raise. Use with to give a PersonRecord a raise (increment Age by 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.

  1. Read the compiler error. Try to mutate a readonly record struct’s property directly (not through with) 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.

  1. Deconstruction. Deconstruct a PersonRecord into 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.

Frequently Asked Questions

What's the difference between a record and a class?
A record gets compiler-generated value-based Equals, GetHashCode, ToString, and a with-expression for non-destructive copies; a regular class gets none of that by default and compares by reference. Use a record when a type's identity is its data (a DTO, a coordinate, a config value); use a class when identity should be by reference, like a tracked database entity.
Are records reference types or value types?
record (or explicitly record class) is a reference type, just like a normal class, with the extra generated members. record struct is a value type — declared the same way but copied by value, like any struct. Both flavors get the same value-based equality.
Is a record automatically immutable?
Not automatically, but it defaults that way. Positional properties on a record class are init-only, so you can only set them during construction or with a with-expression. You can still add a mutable get; set; property inside the body if you need one — records opt into immutability by convention, not by force.
Why does p1 with { ... } not change p1?
with performs a shallow copy of the record with only the listed properties changed, then returns that new object — the original is left untouched. Because record class is still a reference type, this means a new object is allocated on the heap for the copy, distinct from the original.
Can a record inherit from another record?
Yes, but only from another record — a record class can't inherit from a plain class (other than object), and a plain class can't inherit from a record. A derived record's own positional parameters are appended after the base record's, as shown by Point3D : Point(X, Y) adding Z.