AlgoMaster Logo

Records

Last Updated: May 17, 2026

9 min read

Records, added in C# 9, are reference types with value-based equality and immutable-by-default properties. The OOP section covered the basics, declaration syntax, value equality, and when to pick a record over a class. This lesson goes the other direction and opens up the compiler-generated machinery: how with expressions actually work, what positional records produce under the hood, how record structs (C# 10) change the cost model, and how equality behaves across record inheritance hierarchies.

What the Compiler Generates

A one-line positional record declaration produces a surprising amount of code. Take this declaration:

The compiler expands it into roughly the following members on a class named Product:

  • Two public init-only properties, Name and Price, backed by readonly fields.
  • A primary constructor Product(string Name, decimal Price) that assigns both properties.
  • An override of Equals(object?) that delegates to a typed Equals(Product?).
  • A typed Equals(Product?) that compares the runtime type and every property.
  • An override of GetHashCode() that combines the property hash codes.
  • Overloads of operator == and operator != that call the typed Equals.
  • An override of ToString() plus a virtual PrintMembers(StringBuilder) helper.
  • A protected copy constructor Product(Product other) that copies every field.
  • A virtual Clone method (actually emitted with a special name) used by with.
  • A Deconstruct(out string Name, out decimal Price) method.

That's nine or ten members from one line of source. Three of those members are worth a closer look because they show up in the rest of this lesson: the copy constructor, Clone, and PrintMembers.

You did not write any of these members, but they are real members on the type. You can call them, override them, or hide them. The next sections show what happens when you do.

How with Expressions Work

A with expression looks like a mutation but is really a copy plus an assignment to a few properties on the new copy. The compiler lowers original with { Price = 59.99m } into roughly:

  1. Call the protected copy constructor to clone every field of original into a new instance.
  2. Set the Price property on the new instance using its init accessor.
  3. Return the new instance.

The copy constructor is the load-bearing piece. It is generated as:

The compiler also emits a virtual method (with a name that is not legal in C# source, <Clone>$) that returns a new instance built by calling the copy constructor. The with expression calls <Clone>$ so that derived records produce the right concrete type, more on that in the inheritance section.

You can verify the copy by checking reference identity before and after:

Two things to notice. First, discounted is a new object on the heap, the copy constructor allocated it. Second, the Name string is shared between original and discounted because the copy constructor only copies fields, it does not deep-clone reference-typed properties. That is a shallow copy, and it is the right default. Strings are immutable, so sharing is safe. Lists, dictionaries, and other mutable reference types are also shared, which is something to watch for.

You can write your own copy constructor when you need to validate or transform during the copy. The compiler will use yours instead of generating one, as long as it has the correct signature:

The constructor body controls what the copy looks like. The original raw keeps its three-decimal price because it was built through the primary constructor, not the copy constructor. Only the with expression ran the rounded copy.

Positional Records and Deconstruct

Positional records carry one extra ability over body-form records: they can be destructured. The compiler emits a Deconstruct method that mirrors the primary constructor.

The generated Deconstruct looks like:

That makes positional records play nicely with pattern matching. You can match the shape directly inside a switch expression:

The positional pattern ("123 Main St", "Seattle", _) calls Deconstruct behind the scenes and then matches each output against the pattern parts. Body-form records do not get this for free; you would need to add a Deconstruct method by hand.

You can also write your own positional Deconstruct (for example, returning fewer values, or returning derived values), but the parameter names must differ from the primary constructor or you will get a compile-time conflict.

Record Structs

C# 10 added record struct. It is a record where the underlying type is a struct instead of a class. Value semantics for equality were already there with structs, but record structs add the ergonomics: positional declaration, with expressions, generated ToString(), and Deconstruct.

Money instances live on the stack (or inline inside their containing object). Copying happens automatically on assignment, which is the normal value-type story. Equality is generated to compare all fields, and the == operator works.

A key behavior change versus record class: the positional properties of a record struct are mutable by default. The compiler generates { get; set; }, not { get; init; }. The reasoning is that struct fields are commonly mutated in place for performance, so the default leans that way. You can opt back into immutability with readonly record struct:

readonly record struct makes both the type and its positional properties immutable. This is the closest match to standard record class behavior, and it is the right default for true value objects like money, coordinates, and date ranges.

The mutable-by-default rule for record struct is easy to forget when porting code from record class. The next quick check shows why it matters.

Choosing Between record class, record struct, and class

The decision splits along two axes: do you need value semantics, and do you want heap or stack allocation? The table below maps the common cases.

ScenarioBest FitReason
API request/response DTO with 4-12 fieldsrecord classReference semantics fine, equality and ToString are useful.
Money, GeoPoint, DateRange (small, immutable, comparable by value)readonly record structSmall enough to live on the stack, value semantics built in.
Cache key combining a few small fieldsreadonly record structNo heap allocation per lookup, equality works as a dictionary key.
Domain entity with an identity (e.g., Customer with an Id)classIdentity, not values, defines equality. Lifetime matters.
Mutable container that grows over time (e.g., Cart)classState changes are the whole point.
Configuration object loaded once, shared across the apprecord classImmutable, comparable by value, easy to log via ToString.
Performance-critical struct that mutates internally (e.g., a parser state)record struct (mutable)Mutability is intentional, value semantics for equality if needed.
Anything larger than ~16 bytes that compares by valuerecord classStruct copy cost outweighs the heap allocation.

A useful default: reach for record class first when the type is data, switch to readonly record struct only when you have a small value type and the allocation savings matter. Plain class is for objects with identity and behavior.

Record Inheritance

Records can inherit from other records (and only from other records). The derived type adds its own properties and keeps value equality, but the rules around equality across the hierarchy are subtle.

A concrete example. Suppose you have a base Product record and a derived DiscountedProduct that adds a discount percent.

The derived Equals walks the base class's Equals first, then compares its own added properties. Both Name and Price (from Product) and DiscountPercent (from DiscountedProduct) participate. The ToString() output includes the derived type name and all properties from the whole hierarchy.

The interesting case is comparing a base reference to a derived instance. Records use runtime type for equality, not just static type.

Even though both variables are typed as Product and even though the visible fields match, the comparison returns False. The generated Equals calls EqualityContract (a virtual property the compiler emits, returning typeof(ThisRecordType)) and checks that both sides return the same Type. baseRef reports Product, derivedRef reports DiscountedProduct, so they cannot be equal. This is the right call: a DiscountedProduct is a different shape, and treating it as equal to a Product would let callers compare apples to oranges.

with expressions on derived records preserve the runtime type for the same reason. The virtual Clone method gets dispatched, so calling with on a DiscountedProduct referenced through a Product variable still produces a DiscountedProduct:

The with expression saw anyProduct typed as Product but invoked the virtual clone method, which returned a DiscountedProduct. The discount percent rode along untouched.

A small caveat: you cannot use with to set properties that the static type does not expose. Inside a Product-typed variable, with { DiscountPercent = 20m } will not compile, even though the underlying instance has that property. Cast back to the derived type first.

The diagram below shows the inheritance flow for with and equality on a record hierarchy.

The top path is the with expression: it goes through a virtual dispatch, hits the derived copy constructor, and produces an instance of the correct runtime type. The bottom path is the equality check: it short-circuits to false whenever the two sides have different EqualityContract types, regardless of the field values.

One more subtle point. Sealed records skip the virtual EqualityContract check (since there can be no derived type), so equality is slightly cheaper. If your record is a leaf type that will never be derived from, marking it sealed is a small win.

Reference Equality on a Record

Sometimes you want to know whether two record variables point to the same instance, not whether their values match. The == operator is overloaded to do value comparison, so it will not tell you. object.ReferenceEquals does, and it is the standard way to ask.

Worth keeping in mind: a with expression always allocates, so original with { Price = original.Price } (an "empty change") still returns a different instance that compares equal by value. If you are caching records and want to short-circuit when the value hasn't changed, you have to check with == and decide for yourself whether to reuse the original reference.

This matters less for record struct since the concept of reference identity does not really apply, two struct values that compare equal are simply two equal copies of the same data.

Summary of Generated Members

The full set of generated members is worth a single table for reference.

MemberGenerated forPurpose
Public init-only propertiesrecord class, readonly record structImmutable, settable only during construction.
Public get; set; propertiesrecord struct (non-readonly)Mutable. Use readonly record struct to lock them.
Primary constructorPositional records onlyTakes the parameters in order, assigns properties.
Equals(object?) and Equals(TSelf?)All record typesCompares EqualityContract and every property.
GetHashCode()All record typesCombines hash codes of all properties.
== and != operatorsAll record typesDelegate to typed Equals.
ToString() and PrintMembersAll record typesPrints TypeName { Prop1 = Val1, ... }.
Protected copy constructorrecord classBacks the with expression.
Virtual <Clone>$ methodrecord classBacks with for derived types.
DeconstructPositional records onlyEnables var (a, b) = record; and positional patterns.
EqualityContract virtual propertyrecord class onlyReturns the runtime Type. Sealed records inline this.

Override any of these and the compiler uses your version. Two common useful overrides are ToString() (when you want a domain-specific format) and PrintMembers() (when you want to hide a property from the default output). The rest are usually best left to the compiler.

Summary

  • A positional record declaration generates roughly ten members: properties, constructor, equality, hashing, ToString, PrintMembers, a copy constructor, a virtual clone method, and a Deconstruct.
  • A with expression lowers to a virtual clone call that invokes the copy constructor and then applies init setters. It always allocates a new instance and performs a shallow field copy, lists and other mutable references are shared between the original and the copy.
  • record struct (C# 10) is a value-type record with generated equality and with support. Its positional properties are mutable by default; use readonly record struct to make them init-only and lock the whole instance.
  • Choose record class for data over ~16 bytes and readonly record struct for small, equality-driven values like Money, Coordinate, or cache keys. Plain class is still the right call for identity and mutable state.
  • Record inheritance preserves value equality across the hierarchy, but the generated Equals compares an EqualityContract property first, so a base-typed reference is never equal to a derived instance, even when the visible fields match. Sealed records skip that dispatch and are slightly cheaper.
  • with expressions on derived records still produce the correct runtime type because the clone method is virtual. The static type of the variable controls which properties you can set in the with block.
  • You can override any generated member (ToString, PrintMembers, the copy constructor, Equals) by writing your own with the right signature, and the compiler will use yours instead.