C# Records vs Record Structs: Immutability Explained
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
- Reference type
- Positional properties are
init-only by default - Supports inheritance from another record class
- Immutable unless you deliberately write mutable properties
record struct
- Value type
- Positional properties are mutable (
get; set;) by default - Cannot inherit from anything (structs never can)
- Must be declared
readonly record structfor immutability
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
record Point(int X, int Y);
record class Point(...)
record — class is implied either wayrecord struct Point(int X, int Y);
readonly record struct Point(...)
How It Works
public record Shape(string Name);
public record Circle(string Name, double Radius) : Shape(Name);
CircleinheritsShape's members, and the generated equality automatically accounts for the base record's properties too — twoCircles are equal only if both the base and derived properties match.
public record struct Shape2D(string Name);
public record struct Circle2D(string Name, double Radius) : Shape2D(Name); // compile error
- This isn't a record-specific restriction at all — it's the same rule that's always applied to
struct: value types in C# have never supported inheritance (they implicitly inherit only fromSystem.ValueType, and that relationship is sealed). Records don't change that. - A
record structcan implement interfaces, exactly like any other struct — it just can't inherit from another type.
Shape s = new Circle("A", 5);
Shape s2 = new Shape("A");
Console.WriteLine(s == s2); // False — different RUNTIME TYPES, even though "Name" matches
- Generated record equality also compares the runtime type, not just the property values — a
Circleand a plainShapewith the sameNameare never equal, because they're not the same kind of thing.
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
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
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.
- The same
Equals/GetHashCode/ToString/Deconstructshape, adapted to a struct — novirtualmembers (structs are sealed by nature, so there's no polymorphic dispatch to support), and equality doesn't need a runtime-type check the way class records do, since a variable's declared struct type and its actual type are always the same thing. - Properties are plain
{ get; set; }unless the type is declaredreadonly record struct, in which case they become{ get; init; }— matching this lesson's central point. - No hidden copy constructor or
<Clone>$()method is needed forwith— a struct's normal full-field-copy assignment semantics already givewitheverything it needs.
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?
- Use record class for most data models — DTOs, domain events, snapshots — especially any that benefit from an inheritance hierarchy (e.g. a family of related event types).
- Use
readonly record structwhen the data is small, genuinely value-shaped, copied or compared frequently (dictionary/set keys, coordinates, small tuples-with-names), and you specifically want to avoid heap allocation. - Avoid a plain, mutable
record structin almost all cases — if you find yourself reaching for it, ask whether you actually wantedreadonly record structinstead; the mutable form reintroduces exactly the mutable-struct hazards from the previous lesson.
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 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
- A positional record's compiler-generated members: type-aware
Equals,GetHashCode,ToString,Deconstruct, and (for classes) a protected copy constructor plus a hidden<Clone>$()backingwith. record classgeneratesinit-only properties by default (immutable);record structgenerates ordinary mutable properties by default — the opposite default, and the most common source of surprise.- Use
readonly record structto get a genuinely immutable value-typed record. - Record classes support inheritance from other record classes; record structs never support inheritance — the same rule that applies to all structs in C#.
- Reach for a record struct when you want both value semantics and generated equality on small, frequently-copied data — dictionary keys and coordinates are the classic case.
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.