Records ยท How it works

13 min read
Mid-level9 min read
Rapid overview

How it works

Basics

Q: What is a record in C#?

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 properties FirstName, LastName;
  • Deconstruct(out string FirstName, out string LastName);
  • Equals(object), Equals(Person?) (implementing IEquatable<Person>), GetHashCode, operator == / !=;
  • a protected virtual EqualityContract property (the runtime Type, so a base and a derived record are never equal);
  • ToString and a protected virtual PrintMembers(StringBuilder);
  • a protected copy constructor Person(Person original) and a hidden <Clone>$ method that with calls.
Q: What does the compiler generate for a positional record?

A: For record Person(string FirstName, string LastName):

DeclarationKindPositional propertiesEquality
record / record classreference typeinit-only (immutable)value-based, generated
record struct (C# 10)value typeget; set; (mutable)value-based, generated
readonly record structvalue typeinit-only (immutable)value-based, generated
Q: What is the difference between 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.

Q: Can a record have a body, extra members and validation?

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

Q: How do records relate to tuples?

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.

Q: Why does a record serialize correctly when a tuple does not?

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

Q: What does a 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 instance
Q: Is the with 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.

Q: Can you use 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

Q: How is record equality computed?

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.

Q: Why are two records with identical lists not equal?

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).

Q: Is a derived record ever equal to its base?

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.

Q: Can you customise record equality or 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

Q: Are records immutable?

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.

Q: Why should you not use a mutable record as a dictionary key?

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

Q: Can records inherit?

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

Q: What does a 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 GC
Q: What does a record 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.

Q: What does 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.

Q: What does == 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.

Q: What are defensive copies and why does 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.

Q: Why is value equality on a class a GC and correctness concern, not only a speed one?

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

TypeKindWhere the data livesOverhead per instanceAssignment copieswithEquality
(int, int) ValueTuplestructinline in its containernone (8 B total)all fieldscopy, no allocby fields
readonly record structstructinline in its containernoneall fieldscopy, no allocby fields, generated
record classclassown heap object16 B header + method tablethe referencenew heap objectby fields, generated
classclassown heap object16 Bthe referencenot availableby address (unless overridden)
System.Tuple<,>classown heap object16 Bthe referencenot availableby fields (Equals only; == compares addresses)
anonymous typeclassown heap object16 Bthe referencecopy (C# 10), new objectby fields (Equals only)
Q: Compare the options at the memory level.

A: On x64:

Q: When should you use a ValueTuple?

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.

Q: When should you use a 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.

Q: When should you use a 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.

Q: When should you use a plain 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.

Q: When is 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.

  1. Does it have identity and changing state? Use a class.
  2. Does it cross a boundary, get serialized, need a name or have rules? Use a record: readonly record struct if it is small and created in bulk, record class otherwise.
  3. Otherwise (local, private, short-lived, no rules) use a ValueTuple.
Q: Give a rule of thumb for picking one.

A: Ask three questions in order:


When to use a record

Q: Where do records fit best?

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.

Q: When should you not use a record?

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.

Q: Records versus 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

Q: What prints? 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.

Q: Does 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.

Q: What is the 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.

Q: Can a record have a primary constructor without being positional-immutable?

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.

See also