Records ยท How it works
13 min readHow it works
Basics
A: A type declared with the record keyword for which the compiler writes the data plumbing you would otherwise hand-code: value equality, ToString, a copy constructor and with support. The positional form declares the properties in one line:
public record Person(string FirstName, string LastName);
var a = new Person("Ada", "Lovelace");
var b = new Person("Ada", "Lovelace");
Console.WriteLine(a == b); // True - value equality, not reference equality
Console.WriteLine(a); // Person { FirstName = Ada, LastName = Lovelace }- a primary constructor and public
init-only propertiesFirstName,LastName; Deconstruct(out string FirstName, out string LastName);Equals(object),Equals(Person?)(implementingIEquatable<Person>),GetHashCode,operator ==/!=;- a protected virtual
EqualityContractproperty (the runtimeType, so a base and a derived record are never equal); ToStringand a protected virtualPrintMembers(StringBuilder);- a protected copy constructor
Person(Person original)and a hidden<Clone>$method thatwithcalls.
A: For record Person(string FirstName, string LastName):
| Declaration | Kind | Positional properties | Equality |
|---|---|---|---|
record / record class | reference type | init-only (immutable) | value-based, generated |
record struct (C# 10) | value type | get; set; (mutable) | value-based, generated |
readonly record struct | value type | init-only (immutable) | value-based, generated |
record, record class, record struct and readonly record struct?A:
A record struct has mutable positional properties to match tuples and ordinary structs; add readonly if you want immutability. Prefer readonly record struct for small value objects (money, coordinates, IDs) to avoid a heap allocation.
A: Yes. A record is a normal class or struct with extras. You can add methods, computed properties, extra properties, and validate in a property initializer or an explicit constructor:
public sealed record Money(decimal Amount, string Currency)
{
public decimal Amount { get; } = Amount >= 0 ? Amount
: throw new ArgumentOutOfRangeException(nameof(Amount));
public Money Add(Money other) => other.Currency == Currency
? new Money(Amount + other.Amount, Currency)
: throw new InvalidOperationException("Currency mismatch");
}
Redeclaring the positional property (as above) replaces the generated one, which is how you add validation or change accessibility. Note that Amount is now get-only, so money with { Amount = 5 } no longer compiles; declare { get; init; } with the check in the init accessor if you want with to keep working and stay validated.
Records versus tuples: encapsulation
A: Both have value-based equality and support deconstruction, but a tuple is only a composition of values: public mutable fields, names that disappear at runtime, no constructor, no methods. A record is an encapsulated type: it has a real name in the type system, its properties are init-only, its constructor can enforce invariants, it can carry behaviour, and its property names survive into reflection and JSON. Move from a tuple to a record as soon as the values cross a method or API boundary, get serialized, or have rules that tie them together.
A: System.Text.Json serializes public properties, and record members are real properties with real names, so record OrderDto(int Id, decimal Total) becomes {"Id":1,"Total":9.5}. A ValueTuple has fields named Item1, Item2, so it serializes to {} by default.
with expressions
with expression do?A: Non-destructive mutation: it clones the record through the compiler-generated copy constructor, then applies the listed init assignments to the clone. The original is untouched.
var p1 = new Person("Ada", "Lovelace");
var p2 = p1 with { LastName = "Byron" };
// p1 is unchanged; p2 is a new instancewith copy deep or shallow?A: Shallow. Reference-type members are copied as references, so the clone and the original share the same list, array or nested object. Mutating that shared object is visible through both.
public record Order(int Id, List<string> Lines);
var o1 = new Order(1, new() { "A" });
var o2 = o1 with { Id = 2 };
o2.Lines.Add("B");
Console.WriteLine(o1.Lines.Count); // 2 - shared list
Use IReadOnlyList<T> / ImmutableArray<T> for collection members, and replace them explicitly in the with if you need independent copies.
with on non-record types?A: Since C# 10, yes on any struct (including tuples and anonymous types): var t2 = t with { Item1 = 5 };. On classes it only works for records, because it needs the generated clone method.
Equality
A: Two records are equal when they have the same EqualityContract (the same runtime type) and every instance field compares equal with EqualityComparer<T>.Default. That covers the backing fields of all properties, including non-positional ones you add.
A: Equality is member-wise using each member's own equality, and List<T> uses reference equality. new R(new List<int>{1}) and another new R(new List<int>{1}) are not equal. Either use a collection with structural equality, or override Equals(R? other) and GetHashCode (both must stay consistent).
A: No. EqualityContract returns the runtime type, so a Student : Person with the same names is not equal to a Person. This keeps Equals symmetric, a rule hand-written class equality often breaks.
ToString?A: Yes. Declare public virtual bool Equals(Person? other) (on a sealed record, non-virtual) and GetHashCode; the compiler then does not generate them. For printing, override PrintMembers to change which members appear, or ToString (sealing it with sealed override since C# 10 to stop derived records regenerating it). Hide sensitive fields such as passwords this way, because the default ToString prints every public property, including into logs.
Immutability limits
A: Positional record class and readonly record struct properties are init-only, so they are immutable after construction, but only shallowly. Nothing stops you declaring public string Name { get; set; } in a record body, a record struct is mutable by default, and reference-type members can still be mutated through their own API.
A: Its hash code depends on its values. Change a property after inserting it into a HashSet or Dictionary and the entry lives in the wrong bucket: lookups fail and duplicates appear. Keys must be immutable.
Inheritance
A: A record class can inherit only from another record (or object), and a class cannot inherit from a record. record struct cannot inherit at all. Derived records get their own EqualityContract, PrintMembers and clone method, and with on a base-typed variable still produces the derived runtime type.
public abstract record Shape;
public sealed record Circle(double Radius) : Shape;
public sealed record Square(double Side) : Shape;
static double Area(Shape s) => s switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Square(var side) => side * side, // positional pattern via Deconstruct
_ => throw new NotSupportedException(),
};
Sealed record hierarchies plus pattern matching are the usual C# way to model discriminated unions.
Memory level: record class vs record struct
record class look like in memory?A: Exactly like any class. Each instance is a separate object on the managed heap: an 8-byte object header (sync block / lock and hash bits), an 8-byte method-table pointer (its runtime type, which is how GetType() and virtual calls work), then the fields. A variable of the record type holds only an 8-byte reference to it. record Person(string First, string Last) is 32 bytes on x64 (16 overhead + two 8-byte references) plus the two strings it points to.
var p = new Person("Ada", "Lovelace"); // record class, x64
stack frame managed heap
โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ p โ โโโโโโโโโโผโโโโบ โ object header 8 B โ
โโโโโโโโโโโโโโโโ โ method table ptr 8 B โโโโบ type Person
8 B reference โ First โ "Ada" 8 B โโโโบ string
โ Last โ "Lovelace" 8 B โโโโบ string
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
32 B, tracked by the GCrecord struct look like in memory?A: Like a tuple: no header, no method-table pointer, just the fields stored inline in whatever holds it (a stack frame, a containing object, an array). readonly record struct Point(int X, int Y) is 8 bytes. An array of a million Points is one contiguous ~8 MB block; an array of a million record class Point instances is 8 MB of references plus a million 24-byte objects (~32 MB) for the GC to track.
with cost in memory for each kind?A: On a record class, with allocates a new heap object and copies every field into it (references are copied, not the objects they point to: a shallow clone). Doing that in a tight loop creates garbage and GC work. On a record struct, with copies the struct's bytes into a new local: no allocation.
== do at the memory level for records?A: For a normal class == compares the two addresses. For a record it calls the generated Equals, which checks that both have the same runtime type (EqualityContract) and then compares each field with EqualityComparer<T>.Default. So two different objects at different addresses are equal when their contents match, and a reference-type field is compared by its equality (for a List<T>, again by address). ReferenceEquals(a, b) still tells you whether they are the same object.
readonly record struct help?A: When you call a method on a struct stored in a readonly field or received as an in parameter, the compiler cannot prove the method will not modify the struct, so it silently copies it first and calls the method on the copy. For large structs that is a hidden cost on every call. A readonly struct (including readonly record struct) promises that no member modifies state, so the compiler skips the copy. A plain record struct and ValueTuple have mutable members, so they get defensive copies.
A: Because many copies of "the same" value can exist at different addresses. That is fine for immutable data (they are interchangeable), but if one copy is mutated the others do not follow. And a record's hash code is computed from its fields, so mutating a record that is already in a HashSet or Dictionary leaves it in the wrong bucket.
Choosing: tuple vs record struct vs record class vs class
| Type | Kind | Where the data lives | Overhead per instance | Assignment copies | with | Equality |
|---|---|---|---|---|---|---|
(int, int) ValueTuple | struct | inline in its container | none (8 B total) | all fields | copy, no alloc | by fields |
readonly record struct | struct | inline in its container | none | all fields | copy, no alloc | by fields, generated |
record class | class | own heap object | 16 B header + method table | the reference | new heap object | by fields, generated |
class | class | own heap object | 16 B | the reference | not available | by address (unless overridden) |
System.Tuple<,> | class | own heap object | 16 B | the reference | not available | by fields (Equals only; == compares addresses) |
| anonymous type | class | own heap object | 16 B | the reference | copy (C# 10), new object | by fields (Equals only) |
A: On x64:
A: When a few values travel together for a short distance and no rule ties them together: returning two or three values from a private or internal method, a composite dictionary key, in-memory LINQ intermediates, swaps and multi-value switch patterns. It is a free, allocation-free grouping. Stop using it once the values cross a public boundary, get serialized, need a name or need validation.
readonly record struct?A: For small, immutable value objects that deserve a name and rules and that you create in large numbers or on hot paths: Money, Point, DateRange, strongly-typed IDs (OrderId(Guid Value)). Keep them small (the usual guideline is about 16 bytes, stretching to a couple of dozen) because every assignment and parameter pass copies them. You get encapsulation with no allocation and contiguous storage in arrays.
record class?A: For named, immutable data that crosses boundaries: DTOs, API request/response models, commands, queries, events and messages, configuration snapshots. It is also the choice when the data is larger (copying a big struct on every pass costs more than one allocation), when you need inheritance or polymorphism (sealed record hierarchies), when null is a meaningful state, or when many holders should share one instance. You pay one heap allocation per instance and per with.
class instead of a record?A: For entities and services: objects with identity and a lifecycle whose state changes over time (an Order, a Customer, a DbContext, a service). Their equality is by identity (key or reference), not by contents, and EF Core change tracking relies on that. Value-based equality there would make two different orders with the same data look like one.
System.Tuple or an anonymous type still the right choice?A: System.Tuple only when a legacy API requires it. Anonymous types for projections inside an IQueryable expression that EF Core must translate to SQL (Select(x => new { x.Id, x.Name })), because expression trees cannot contain tuple literals. Neither should appear in a method signature.
- Does it have identity and changing state? Use a
class. - Does it cross a boundary, get serialized, need a name or have rules? Use a record:
readonly record structif it is small and created in bulk,record classotherwise. - Otherwise (local, private, short-lived, no rules) use a
ValueTuple.
A: Ask three questions in order:
When to use a record
A: DTOs and API request/response models; commands, queries, events and message contracts (MassTransit, MediatR); DDD value objects (Money, Email, DateRange), ideally as readonly record struct or sealed record with validation; configuration snapshots; and results of pure functions.
A: For entities with identity and a lifecycle (an Order with an Id whose state changes): two orders are the same when their IDs match, not when all fields match, and EF Core change tracking relies on reference identity, so value equality on an entity causes subtle bugs. Also avoid records for types that mainly hold mutable state or behaviour, such as services.
class with hand-written equality versus struct?A: A record gives the same result as a hand-written immutable class with IEquatable<T>, GetHashCode, ==, ToString and a copy method, in one line and without the usual symmetry and hash-consistency bugs. A plain struct also has value equality, but its default Equals uses reflection (slow) unless every field is a blittable value, and it has no == operator; record struct generates both efficiently.
Common interview gotchas
record P(int X); var a = new P(1); var b = a with { }; Console.WriteLine(ReferenceEquals(a, b) + " " + (a == b));A: False True. with { } always creates a new instance, and the two are equal by value.
with run the constructor's validation?A: No. with calls the copy constructor and then the init accessors, not the primary constructor, so validation written only in the constructor is bypassed. Put validation in the property init accessor if it must hold after a with.
EqualityContract for?A: It is a protected virtual Type property compared inside Equals, so records of different runtime types are never equal, even if the base-class fields match.
A: Since C# 12, ordinary classes and structs also have primary constructors, but only records turn the parameters into public properties. In a plain class the parameters are just captured variables in scope of the body.