AlgoMaster Logo

Record Struct

Last Updated: May 17, 2026

15 min read

A record struct is a value type with compiler-generated value equality, a readable ToString, deconstruction, and with expressions. It's what you reach for when a plain struct would be a fine choice for performance, but you also want the small bundle of helpers that records give you for free. This lesson covers the syntax, the mutable-by-default trap, the readonly record struct form that fixes it, and how the type lines up against class records and other struct flavors.

Why Record Structs Exist

Records are reference types, allocated on the heap, with value-based equality. They're a great fit for data transfer objects and small immutable bundles. But every record class allocation goes on the heap, and for tiny types passed around in hot loops (a Money, a Coordinate, a ProductId), that allocation cost adds up.

Plain structs solve the allocation problem because they're value types stored inline on the stack or directly inside the containing object. But a plain struct gives you reference-style equality unless you write Equals and GetHashCode by hand, which is tedious and easy to get wrong. You also get a default ToString that just prints the type name, which is useless for debugging.

Record structs (added in C# 10) close the gap. You get the value-type storage of a struct, the value-based equality of a record, and the readable ToString without writing any of it yourself. The compiler generates Equals, GetHashCode, ToString, Deconstruct, ==, and != for you based on the declared fields and properties.

The decision usually comes down to two questions: do you want value-based equality (yes for almost anything data-shaped), and can the type afford to live on the heap (yes for most things, no for tiny values used in tight loops). When the answer to the first is yes and the second is no, record struct is the right tool.

The struct basics lesson already covered the core of how value types behave: they're copied on assignment, stored inline rather than on the heap, and they don't need a new allocation in the garbage-collected sense. The readonly struct lesson covered how to make those value types immutable. Record struct combines those ideas with the record family's auto-generated members, so you get the storage and immutability of readonly struct plus the equality and ToString of record, all in one declaration.

Declaring a Record Struct

The simplest form is positional, the same shape you've seen with class records:

One line, and you've got a value type with two properties, a constructor, a working Equals, a hash code, and a ToString that prints the field values. The record struct keyword pair tells the compiler "make this a value type, but generate the record helpers."

You can also write it with a body, the same way you would with a class record:

The body form is useful when you want default values, custom validation, or extra members. It produces the same generated helpers as the positional form.

A subtle but important detail shows up in the body version above: the properties use { get; set; }, not { get; init; }. That's not a typo. It's the default for record structs, and it trips up most people on first read.

You can also use field declarations in the body if you want raw fields rather than properties:

Public fields work the same way they do on a normal struct: the compiler treats them as part of the type's data, so they participate in the generated Equals, GetHashCode, and ToString. That said, properties are still the convention for record structs the same as for any other C# type. Use fields only when you have a specific reason to.

The Mutable-by-Default Trap

Here's the part that surprises everyone who already knows class records. A class record's positional properties are init-only by default. The same positional syntax on a record struct gives you get; set; properties instead.

Compare the two:

The same one-line positional declaration produces an immutable type when you write record class, and a mutable type when you write record struct. There's no warning, no nudge from the compiler. If you're translating a class record to a struct record because you want a value type, you'll silently lose the immutability guarantee.

What's wrong with this code?

The key variable is a value type, so when it was used as a dictionary key the dictionary stored a copy with Amount = 10m. Mutating key.Amount afterwards only changes the local copy. The dictionary's stored key is still { Amount = 10, Currency = USD }, so ContainsKey(new Money(10m, "USD")) correctly returns True. But this is a fragile design: it's easy to imagine a similar example where someone passes a Money to a method, that method mutates the copy, and the caller thinks the mutation traveled back. The fix is to make the type immutable.

Fix:

The readonly modifier on the type declaration flips the positional properties from get; set; to get; init;, giving you the immutability you almost certainly wanted in the first place.

readonly record struct: The Recommended Default

Most uses of record struct should be readonly record struct. The reasoning lines up exactly with why class records are immutable by default: a small data-shaped type is easier to reason about when it can't change after construction, and the readonly keyword makes that explicit on the type.

The commented line fails to compile with CS8852, the same error you'd get from a class record. The type now behaves the way most readers expect: build it once, pass it around, no surprises.

The body form respects the type-level readonly too. If you write readonly record struct and then define a property with { get; set; } inside, the compiler rejects it:

The error reminds you that the type promised to be read-only, so each property has to honor that. Switch the setters to init and the code compiles.

A useful rule of thumb: reach for readonly record struct first. Drop the readonly only if you've thought about it and decided that field-by-field mutability really is the design you want, which is rare in practice.

The same rule applies if you're starting from a readonly struct and adding value-equality helpers. The minimum change is adding the record keyword:

The "after" version is a third of the length and gives you Equals, GetHashCode, ToString, Deconstruct, ==, and != for free. If you find yourself writing the "before" version, the "after" version is almost always what you actually wanted.

What the Compiler Generates

A record struct gets a small but useful set of generated members. Knowing what's in there helps when you're reading code, looking at decompilations, or debugging odd behavior.

The properties and constructor come from the positional list. The equality members (Equals, GetHashCode, ==, !=) compare field values, the same way they do on class records. The ToString override prints the type name and every field. Deconstruct lets you pull the fields back out into separate variables in one line.

One thing worth highlighting: a class record's Equals participates in an inheritance check using a generated property called EqualityContract. Record structs don't need this because structs can't inherit from other structs, so there's nothing to check. Equality is plain field-by-field comparison.

Here's the equality behavior in code:

a and b are different variables holding different copies of the value (record structs are value types, so each variable is its own bundle of bytes), but the field values match, so they compare equal. c has a different currency, so it's not equal. The hash codes for a and b match because the fields match, which is what lets you use record structs as dictionary keys.

There's a small but useful detail here. Record structs implement IEquatable<T> automatically, where T is the record struct type itself. That means the Equals(Money other) overload is the one used when the compiler can resolve the call statically (such as inside Dictionary<Money, ...> or List<Money>.Contains). The Equals(object) overload still works for cases where the type isn't known at compile time, but it requires a type check and an unbox if the value was boxed. The IEquatable<T> overload avoids both.

Deconstruction

The compiler generates a Deconstruct method for positional record structs. That lets you split an instance back into its parts in one line:

The var (lat, lng) = seattle; line calls the generated Deconstruct(out double Latitude, out double Longitude) method. The parameter names match the positional property names. Deconstruction is also useful inside switch expressions and pattern matching, which the later C# pattern matching lessons cover.

If you declare the record struct using the body form rather than the positional form, you don't get Deconstruct for free. You'd need to write it yourself. That's one more reason the positional form is the typical default.

Deconstruction also pairs nicely with pattern matching in a switch expression:

The positional patterns like (< 10m, "USD") call into the generated Deconstruct, then match the resulting components. The first match wins. This kind of compact branching is one of the reasons records and record structs are popular for domain modeling.

The with Expression on Record Structs

When you want to build a modified copy of a record struct, the with expression handles it the same way it does on class records:

price is untouched. discounted is a new Money value with the amount replaced and the currency carried over. On a value type, "a new copy" is just a new set of bytes on the stack or wherever the variable lives. There's no heap allocation involved, which is one of the appeals of using a record struct over a record class for small values that get modified frequently.

with works on both record struct and readonly record struct. On a mutable record struct it's still useful because it produces a brand new value with selected changes, leaving the source value alone.

Under the hood, the compiler implements with on a record struct as roughly: "copy the source value bitwise, then assign the listed properties on the copy." There's no virtual call, no separate allocation, just a few moves and stores. For a small record struct, the JIT typically inlines the whole thing into a handful of register operations. That's the kind of cost profile you want when you're "modifying" thousands of these per second.

Changing multiple fields in one expression works the same way:

The with block lists every property you want to override; everything you don't list carries over from the source value. There's no requirement to override exactly one, you can override one, two, or all of them in a single expression.

Positional Syntax With a Body

You can mix the positional form with a body when you want extra members. The positional parameters still generate the properties and constructor, and the body adds methods, computed properties, or constants:

The Duration property is computed from the existing fields and doesn't affect equality. Contains is a regular method on the value. Adding behavior like this is fine for a record struct as long as the methods don't try to mutate state. On a readonly record struct, attempting to assign to one of the positional properties from a method inside the body fails to compile.

Value-Type Storage and Performance

The reason to pick a record struct over a record class is storage. Record structs are value types, which means:

  • They're stored inline. A local Money variable lives on the stack. A Money field inside a record class lives directly inside that class's heap allocation, not as a separate object.
  • They're copied on assignment and on parameter passing. var b = a; produces a fresh copy of every field. Calling void Charge(Money price) and passing a Money makes a copy on each call.
  • No garbage collection pressure. There's nothing to allocate, and nothing to reclaim.

Compare the memory layout of a class record and a record struct holding the same data:

The class version is one heap allocation per instance, plus the object header (a few extra bytes for the runtime's bookkeeping), plus a reference. The struct version is just the fields, sitting wherever the variable lives.

Concretely, on a 64-bit runtime, a record class Money instance includes roughly 16 bytes of object header (a type pointer and a sync block), plus the Amount (16 bytes for decimal) and the Currency reference (8 bytes), for around 40 bytes on the heap. The variable holding the reference is another 8 bytes. A readonly record struct Money skips the header entirely: the variable holds Amount (16 bytes) and Currency (8 bytes) directly, for 24 bytes total, all stored inline. The savings are about a third per instance, plus the avoided garbage collector pressure.

For a Money used a few times, none of this matters. For a Money used in a tight loop computing cart totals over millions of orders in a background job, switching from class record to readonly record struct can be a measurable win.

Boxing is the usual gotcha. If a record struct ends up assigned to object, stored in a non-generic collection, or passed somewhere expecting an interface, it gets boxed onto the heap. That defeats the storage advantage. The struct basics lesson covered this in more detail, so the short version is: use record structs through their concrete type or through generic containers (like List<Money>) that don't box.

What Record Struct Does Not Generate

A class record generates a few members that don't make sense for a struct, and the compiler simply leaves them out:

  • No `Clone` method. Class records have a generated copy constructor and a protected <Clone> method that with expressions use. Structs are copied bitwise on assignment, so there's no need for a clone operation. The with expression on a record struct uses the bitwise copy directly.
  • No inheritance hierarchy. Structs can't inherit from other structs (they always inherit from System.ValueType). That means a record struct can't have a base record struct, and other record structs can't derive from it.
  • No `EqualityContract`. Class records use this property to ensure two records compared as equal are also of the same runtime type, which matters when a class record is inherited. Record structs don't inherit, so the check is unnecessary.

The lack of inheritance is the most common point of friction when migrating a class record to a record struct. If your class record has a hierarchy (Animal -> Dog, Cat), you can't translate that into record structs. You'd either keep the class record or restructure the design (often into a tagged union or a discriminated record pattern, which the pattern matching lessons cover).

Record structs can still implement interfaces, even though they can't inherit. That's useful when you want a record struct to fit into a generic API that constrains on an interface:

The method call above happens without boxing because the call is on the concrete struct type. The moment you assign id to a variable of type IPrintable, the runtime boxes the value onto the heap, which defeats the storage advantage. Generic constraints (where T : IPrintable) avoid the boxing because they preserve the concrete type at call sites.

Here's a quick side-by-side of the generated members:

Memberrecord classrecord struct
Equals(T) and Equals(object)YesYes
GetHashCode()YesYes
operator == and operator !=YesYes
ToString()Yes (Type { Prop = Val })Yes (Type { Prop = Val })
Deconstruct(out ...) (positional only)YesYes
with expression supportYes (via copy constructor + <Clone>)Yes (via bitwise copy)
EqualityContract propertyYesNo
Inheritance from another recordYes (single record base)No
Default property accessorget; init;get; set; (or init if readonly)

The right column is what you get from record struct. The shared rows are the value-equality-and-friends story. The differences are mostly about what structs can't physically do (inherit, allocate separately).

Comparing the Four Options

Once you have class, record class, struct, readonly record struct, and a couple of other flavors in scope, picking the right one feels harder than it actually is. The table below maps the choice to two questions: does the type need value semantics (data, not identity), and is the type small enough to live on the stack?

TypeValue-based equalityAllocationMutability defaultTypical use
classNo (reference equality)HeapMutableServices, behavior, entities with identity
record classYesHeapImmutable (init)DTOs, query results, cache keys, mid-size data
structNo (default field equality with boxing)Stack/inlineMutableSmall numeric or interop types
readonly structNoStack/inlineImmutableSmall numeric or interop types where mutation would be wrong
record structYesStack/inlineMutableRare; small value types that genuinely need mutability
readonly record structYesStack/inlineImmutable (init)Money, Coordinate, ProductId, small immutable values with value equality

The right-hand column is the practical guide. For an e-commerce codebase:

  • Order, Cart, Customer, OrderRepository are classes.
  • API request and response DTOs, search results, cached query payloads are record classes.
  • Money, Coordinate, ProductId, DiscountCode, DateRange are readonly record structs.

That covers nearly every type you'll write. The two middle rows (struct, record struct without readonly) come up less often than the others.

A Worked Example: Money, ProductId, Coordinate

Putting it all together, here's a small piece of an e-commerce domain that uses several record structs alongside a regular class:

A few things to notice. Money has both data (Amount, Currency) and a small piece of behavior (Add). The behavior uses with to build a new value rather than mutating an existing one, which is the only option since the type is readonly. ProductId is a single-field record struct that wraps an int; the value-equality means it works as a dictionary key with no extra effort. Cart is a class because it has mutable state (the dictionary of items grows over time).

The records pulled into the dictionary don't allocate on the heap individually. The Dictionary<ProductId, Money> stores its entries in an internal array, and each entry holds the ProductId and Money inline. Compared to using class records for the same shape, you save two heap objects per cart item.

A Common Gotcha: Mutable Fields in a Mutable record struct

There's one more sharp edge worth pointing out. If you declare a record struct without readonly, and that record struct has fields rather than properties, those fields are mutable. That can interact badly with foreach and other places where the runtime hands you a copy of the value:

The foreach loop variable c is a copy of the array element, not a reference to it. Incrementing c.Count modifies the local copy, then the copy is discarded at the end of the loop iteration. The array entries don't budge. This is the same gotcha as with any mutable struct, and it's exactly why mutable structs (record or not) are discouraged. Using readonly record struct here would have made the increment a compile error, which catches the problem early.

The fix is to either index into the array (counters[i].Count++ works because [] on an array gives you a reference to the element) or, much better, redesign the type to be immutable and produce updated copies.

The immutable redesign looks like this:

Now there's no ambiguity. The with expression produces a new value, and the assignment writes that new value back into the array slot. The reader can tell at a glance that the array is being updated. The version with Count++ on a foreach variable looked like it updated the array but didn't, which is the worst kind of bug, the kind that compiles cleanly and runs but produces wrong results.

Summary

  • A record struct is a value type with compiler-generated value equality, GetHashCode, ToString, Deconstruct, ==, and !=. It was added in C# 10.
  • Positional syntax (public record struct Money(decimal Amount, string Currency);) generates the properties and constructor in one line, the same as a class record.
  • A plain record struct is mutable by default: its positional properties are get; set;. This is the opposite of class records, where positional properties are init-only.
  • readonly record struct is the recommended default. It flips the properties to init-only and prevents the mutable-struct gotchas, with zero runtime cost.
  • The with expression works on record structs and uses the runtime's bitwise struct copy. There's no separate generated Clone method.
  • Record structs can't inherit from other structs. If you need an inheritance hierarchy, use class records.
  • Keep record structs small (rule of thumb: under 16 bytes of payload). Big record structs lose the storage advantage to copy costs.
  • Use readonly record struct for Money, ProductId, Coordinate, DateRange, DiscountCode, and similar small value types with value semantics. Use record class for mid-size DTOs. Use class for anything with mutable state or behavior.