← Open in the full interactive course (progress tracking, search & more)

A record struct is not automatically immutable — that single misconception causes more production surprises than almost anything else in this module.

You already know records from Foundations: value-based equality, a readable ToString, and with expressions, all generated from a one-line declaration. That's the pitch. What Foundations didn't need to cover — but a production codebase absolutely does — is exactly what the compiler writes on your behalf, what happens when a record inherits from another record, and a genuinely dangerous trap: record struct is not automatically immutable the way a class-based record is. Get that one wrong and you've built a value type that silently invites the exact mutable-struct bugs the previous lesson just warned you about — wrapped in the deceptively safe-looking word "record."

In this lesson, you'll see exactly what the compiler generates for a positional record, the real difference between record class and record struct mutability, how record inheritance works (and why it's a class-only feature), and when a record struct genuinely beats a record class.

What Is It?

The Quick Recap

A record is a type declaration that gets compiler-generated value equality, a readable ToString, and with-expression support. record and record class mean the same thing — a reference type. record struct applies the same generated machinery to a value type. That's the Foundations-level summary. This lesson is about the parts that only matter once you're actually shipping records in production code.

The Mutability Rule That Trips Everyone Up

This is the single most important fact in this lesson, so it's stated plainly before anything else: a positional record class generates init-only properties by default; a positional record struct generates ordinary mutable { get; set; } properties by default. The two keywords look parallel — both say "record" — but their default mutability is opposite.

public record Point(int X, int Y);          // record class: X and Y are init-only — immutable

public record struct Coord(int X, int Y);    // record struct: X and Y are get; set; — MUTABLE!

var c = new Coord(1, 2);
c.X = 99; //  compiles fine — a plain record struct is a mutable value type

To get a genuinely immutable record struct, you must say so explicitly:

public readonly record struct Coord(int X, int Y); // NOW X and Y are init-only — truly immutable

var c = new Coord(1, 2);
c.X = 99; //  compile error — exactly what most people assumed "record struct" already gave them

record / record class

record struct

Why Does It Exist?

The Problem

Before record struct (C# 10), if you wanted value-type semantics (stack allocation, copy-by-value, no GC pressure) and compiler-generated value equality, you had to hand-write it — a struct with a manually overridden Equals, GetHashCode, and ToString, exactly the same boilerplate records were invented to eliminate for classes.

The Need

Developers needed the record ergonomics — generated equality, printing, deconstruction, with — for small, frequently-copied value types too, without giving up struct's stack-allocation and copy-by-value characteristics.

The Solution — And Why Its Default Differs From record class

C# 10 added record struct, generating the same equality/printing/deconstruction/with machinery on a value type. But the language designers made record structs mutable by default, matching how ordinary structs had always defaulted — rather than immutable by default like record classes. The reasoning: an ordinary struct in C# has always defaulted to mutable fields, and changing that expectation specifically for record struct (while struct itself stayed mutable-by-default) would have been its own source of confusion. The trade-off is that you must opt in to immutability explicitly with readonly record struct — the same discipline the previous lesson recommended for any struct you design.

Big Picture

FOUR VARIANTS, FOUR DIFFERENT DEFAULTS

record Point(int X, int Y);

Reference type · immutable (init-only) by default

record class Point(...)

Identical to plain recordclass is implied either way

record struct Point(int X, int Y);

Value type · mutable (get/set) by default — the trap

readonly record struct Point(...)

Value type · immutable (init-only) — what most people actually want

How It Works

RECORD INHERITANCE — CLASS-ONLY, STEP BY STEP
1. RECORD CLASSES CAN INHERIT FROM OTHER RECORD CLASSES
public record Shape(string Name);
public record Circle(string Name, double Radius) : Shape(Name);
2. RECORD STRUCTS CANNOT INHERIT FROM ANYTHING
public record struct Shape2D(string Name);
public record struct Circle2D(string Name, double Radius) : Shape2D(Name); //  compile error
3. EQUALITY IN AN INHERITANCE CHAIN IS TYPE-AWARE
Shape s = new Circle("A", 5);
Shape s2 = new Shape("A");
Console.WriteLine(s == s2); // False — different RUNTIME TYPES, even though "Name" matches

Simple Example

// A genuinely immutable, value-typed coordinate — the idiomatic way to write one
public readonly record struct GeoCoordinate(double Latitude, double Longitude);

var a = new GeoCoordinate(51.5074, -0.1278);
var b = a with { Latitude = 51.51 }; // 'with' works on record structs too — produces a NEW value

Console.WriteLine(a);            // GeoCoordinate { Latitude = 51.5074, Longitude = -0.1278 }
Console.WriteLine(a == b);       // False — different Latitude
Console.WriteLine(a.GetType());  // GeoCoordinate — a genuine struct, no heap allocation for 'a' itself

Meaning: with works identically on record structs and record classes — it always produces a new value rather than mutating in place. For a readonly record struct, that's the only way to get a changed value at all, since there's no mutable setter to fall back on.

Real-World Example

A game or simulation tracking thousands of small position updates per frame is exactly where readonly record struct earns its keep — value semantics and equality checking, without heap allocation for every position.

public readonly record struct GridCell(int Row, int Col);

public class PathfindingGrid
{
    private readonly HashSet<GridCell> _visited = [];
    private readonly HashSet<GridCell> _obstacles;

    public PathfindingGrid(IEnumerable<GridCell> obstacles) => _obstacles = [.. obstacles];

    public bool TryVisit(GridCell cell)
    {
        if (_obstacles.Contains(cell) || !_visited.Add(cell))
            return false; // blocked, or already visited — GridCell's generated Equals/GetHashCode make this work correctly in a HashSet
        return true;
    }
}

var grid = new PathfindingGrid([new GridCell(2, 3), new GridCell(4, 1)]);
grid.TryVisit(new GridCell(0, 0));           // true — new cell
grid.TryVisit(new GridCell(0, 0) with { });   // false — already visited; value equality recognizes it as "the same cell"

Because GridCell is a readonly record struct, it can be used as a HashSet<T>/Dictionary<TKey,_> key correctly out of the box (generated GetHashCode/Equals) while living entirely on the stack or inline inside the set's internal storage — no per-cell heap allocation for thousands of grid positions, and no risk of a cell silently "changing under you" once it's been added to a set.

Analogy

Two label makers, one printing sealed labels by default

Think of record class as a label maker that prints a sealed, laminated label by default — you can't scratch it and change it, only print a fresh one (with). record struct is a second label maker, from a different manufacturer, that by default prints on a dry-erase label — perfectly legal to write over, unless you specifically buy the "laminated" model (readonly record struct).

Both label makers print labels that read identically at a glance ("record" is on both boxes), which is exactly why reaching for the wrong one is such an easy mistake — you have to check the fine print (the keyword) to know whether what you're holding can be altered after the fact.

Under the Hood

WHAT THE COMPILER ACTUALLY GENERATES FOR A POSITIONAL RECORD
public record Point(int X, int Y); EXPANDS TO ROUGHLY THIS
public class Point : IEquatable<Point>
{
    public int X { get; init; }
    public int Y { get; init; }

    public Point(int x, int y) { X = x; Y = y; }

    // Equality — compares runtime type AND every property
    public override bool Equals(object? obj) => Equals(obj as Point);
    public virtual bool Equals(Point? other) =>
        other is not null && GetType() == other.GetType() && X == other.X && Y == other.Y;
    public static bool operator ==(Point? a, Point? b) => a?.Equals(b) ?? b is null;
    public static bool operator !=(Point? a, Point? b) => !(a == b);
    public override int GetHashCode() => HashCode.Combine(X, Y);

    // Printing
    public override string ToString() => $"Point {{ X = {X}, Y = {Y} }}";

    // Destructuring — pairs with positional patterns (next lesson)
    public void Deconstruct(out int x, out int y) { x = X; y = Y; }

    // Backs 'with' — a protected "copy constructor" plus a hidden clone method
    protected Point(Point original) { X = original.X; Y = original.Y; }
    public virtual Point <Clone>$() => new Point(this);
}

A with expression compiles to a call to that hidden <Clone>$() method, followed by setting whichever init properties you specified in the braces. The copy constructor is protected specifically so a derived record can call it via base(original) when inheriting — that's the mechanism inheritance and with share underneath.

record struct — SAME IDEA, VALUE-TYPE SHAPE
public record struct Coord(int X, int Y); GENERATES

Common Confusion

1. "It's a record, so it must be immutable" — false for record struct

This is worth repeating because it's the single most common wrong assumption: a plain record struct is mutable by default. "Record" guarantees you get generated equality, printing, and with support — it does not, by itself, guarantee immutability for the struct variant. Only readonly record struct does.

2. Record inheritance is not the same feature as struct-vs-class inheritance in general

Record classes support the exact same single-inheritance rules ordinary classes always have — nothing new there. The one genuinely new wrinkle records add is that equality automatically becomes type-aware across the hierarchy (a base-typed reference to a derived record still compares correctly, as shown in "How It Works"), something you'd otherwise have to hand-write carefully.

3. A record struct's Equals is not virtual, and that's fine

Class-based record equality is virtual to support the inheritance chain correctly. A record struct's generated members are never virtual, because structs can't be inherited from — there's no polymorphism to support. This isn't a missing feature; it's simply not applicable to a sealed-by-nature value type.

Common Mistakes

Mistake 1 — Writing record struct when you meant "immutable value type"

public record struct Money(decimal Amount, string Currency); for something you intend to treat as an immutable value — this compiles, and every property is silently mutable.

public readonly record struct Money(decimal Amount, string Currency); — make the intent explicit and enforced.

Mistake 2 — Trying to make a record struct inherit from another record

public record struct Base(int A);
public record struct Derived(int A, int B) : Base(A); //  compile error

If you need an inheritance hierarchy of data types, use record class (which supports it); if you specifically need value-type semantics, compose or duplicate the shared shape instead — value types never support inheritance, records included.

Mistake 3 — Assuming a derived record's equality only checks its own new properties

public record Shape(string Name);
public record Circle(string Name, double Radius) : Shape(Name);

Shape a = new Circle("X", 5);
Shape b = new Shape("X");
Console.WriteLine(a.Equals(b)); // False — many assume this is True because "Name" matches

Generated equality always compares the runtime type first — a Circle is never equal to a plain Shape, even with matching shared properties, exactly because they represent genuinely different kinds of data.

When Should I Use It?

Rule of thumb: If you type record struct, stop and type readonly in front of it unless you have a specific, deliberate reason for mutability. That single habit avoids the most common record-related bug in this module.

Mental Model

record/record class = reference type, immutable by default, supports inheritance.
record struct = value type, mutable by default (!), never supports inheritance.
readonly record struct = value type, immutable, the form you almost always actually want.

Remember: "record" promises generated equality/printing/with — it does NOT promise immutability for the struct variant. Say readonly out loud every time you write record struct.

Key Takeaway


Check Your Understanding

You've seen what the compiler generates for a record, and — critically — the mutability trap between record class and record struct. Let's check your understanding.

1. Given public record struct Coord(int X, int Y);, what happens when you write var c = new Coord(1, 2); c.X = 5;?

Show answer

Correct: B

Why B is correct: This is the central point of the lesson — a plain record struct generates ordinary mutable properties by default, the opposite of a class-based record's init-only default. c.X = 5; compiles and genuinely mutates c.

Why A is incorrect: That's true for record/record class, but specifically not true for a plain record struct — this asymmetry is exactly what this lesson exists to correct.

Why C is incorrect: There's no runtime exception involved; the property genuinely has a working setter.

Why D is incorrect: c here is a real local variable, not a temporary copy handed back from a property or foreach — the mutation is real and visible.

Reinforcement: Only readonly record struct gives you the immutable behavior most people expect from the word "record."

2. Which record variant supports inheriting from another record of the same category?

Show answer

Correct: C

Why C is correct: A record class inherits the exact same single-inheritance rules ordinary classes have always had. A record struct, being a struct at heart, follows the same "structs never support inheritance" rule that has always applied in C# — records don't change that.

Why A is incorrect: This reverses the actual rule — struct-based types are the ones that can never inherit.

Why B is incorrect: Only the class variant supports it; attempting inheritance on a record struct is a compile error.

Why D is incorrect: Record classes do support inheritance from other record classes, as shown in "How It Works."

Reinforcement: Whether a type can be inherited from is a struct-vs-class question first — "record" doesn't override that fundamental rule either way.

3. You need small, immutable coordinate values to use as dictionary keys, with no per-instance heap allocation. Which declaration best fits?

Show answer

Correct: B

Why B is correct: This gives generated equality/hashing (needed for correct dictionary-key behavior), true immutability (safe once used as a key — a mutable key is a well-known correctness hazard for dictionaries and sets), and value-type storage (no per-instance heap allocation).

Why A is incorrect: Without readonly, the coordinate is mutable — mutating a key after it's been inserted into a dictionary or set can silently break lookups, since the stored hash no longer matches the (now-changed) value.

Why C is incorrect: A plain record is a reference type — each instance is a separate heap allocation, which the scenario explicitly wants to avoid.

Why D is incorrect: A hand-written mutable class gives you neither generated equality nor immutability, and still allocates on the heap — strictly worse than option B for this scenario on every axis.

Reinforcement: readonly record struct is the idiomatic choice precisely when you want value semantics, generated equality, and guaranteed immutability all at once.

4. Given public record Shape(string Name); and public record Circle(string Name, double Radius) : Shape(Name);, why does ((Shape)new Circle("X", 5)).Equals(new Shape("X")) evaluate to false?

Show answer

Correct: B

Why B is correct: As shown in "Under the Hood," generated equality checks GetType() == other.GetType() before comparing properties. A Circle instance and a Shape instance are different runtime types, so they can never be equal — even sharing every base property.

Why A is incorrect: Both instances do have Name == "X" — the mismatch is entirely about runtime type, not property values.

Why C is incorrect: This is intentional, correct, well-documented behavior — treating a Circle as equal to a plain Shape just because base properties match would be the actual bug.

Why D is incorrect: Circle does inherit and correctly override the equality machinery — it's virtual specifically so this works correctly through a base-typed reference.

Reinforcement: Record equality is "same data AND same runtime type" — not "same data alone."

You now know exactly what a record generates, and — most importantly — the mutability trap that separates record struct from readonly record struct.


dotnetmadeeasy.com — Learn C# and .NET, the right way.