C# Records for Immutable DTOs: Equality, Copies, and Limits
Records reduce boilerplate for immutable DTOs and value objects by generating value-based equality. Learn how positional records work, where shallow equality bites, and how to verify the behavior.
17 Jul 2026, 13:13 UTC

The boilerplate problem records solve
When you model a value object or a data-transfer object (DTO)—a type whose identity is its data rather than its memory address—a plain class gives you the wrong equality semantics. Two Money instances with the same amount and currency are not equal under reference equality, so you end up hand-writing Equals, GetHashCode, often ToString, and a copy mechanism. C# records remove that boilerplate for small, immutable data carriers.
The useful takeaway: use a sealed record when the primary requirement is value equality and immutability. The compiler generates the equality members, and with expressions give you non-destructive mutation.
A worked Money value object
Consider a monetary amount. The following positional record declares two properties and a constructor in one line:
public sealed record Money(decimal Amount, string Currency);
Usage:
var a = new Money(10m, "USD");
var b = new Money(10m, "USD");
Console.WriteLine(a == b); // True
Console.WriteLine(ReferenceEquals(a, b)); // False
var c = a with { Amount = 12m };
Console.WriteLine(c); // Money { Amount = 12, Currency = USD }
Console.WriteLine(a.Amount); // 10
Two distinct instances compare equal because the compiler generated ==, !=, Equals, and GetHashCode based on the property values. The with expression creates a modified copy through init-only accessors, leaving the original unchanged.
What the compiler generates
For a positional record, the C# compiler emits:
- Value-based
EqualsandGetHashCodethat compare each property using its own equality. ToStringthat prints property names and values.Deconstructso you can writevar (amount, currency) = a;.initaccessors for positional properties, enablingwithexpressions.
If you need custom behavior—for example, case-insensitive currency comparison—you can still declare your own Equals and GetHashCode, but you take responsibility for keeping them consistent.
Equality is sensitive to the runtime record type
Record equality checks the runtime type, not just the declared type. If you derive from a record, a base instance and a derived instance with identical field values are not equal:
public record BasePoint(int X, int Y);
public sealed record DerivedPoint(int X, int Y, string Label) : BasePoint(X, Y);
var b = new BasePoint(1, 2);
var d = new DerivedPoint(1, 2, "origin");
Console.WriteLine(b == d); // False
This is usually the correct behavior for value objects, but it surprises developers who expect structural equality across an inheritance hierarchy. Prefer sealed on records unless inheritance is intentional; sealing also lets the compiler devirtualize equality calls.
Limits and common mistakes
Shallow equality for collections
Generated equality is shallow. If a record contains a collection, the collection is compared by reference unless the collection type itself supplies value equality:
public sealed record OrderLine(string Sku, List<string> Tags);
var l1 = new OrderLine("A", new List<string> { "red" });
var l2 = new OrderLine("A", new List<string> { "red" });
Console.WriteLine(l1 == l2); // False
Two List<T> instances with identical contents are different objects, so the records are unequal. If you need collection equality, either use a type that implements value equality (such as ImmutableArray<T> in some contexts) or write a custom Equals.
Mutable members and unstable hash codes
Records are reference types by default, and positional properties are init-only, which discourages mutation. However, you can declare mutable properties on a record. Doing so breaks the contract for hash-based collections: if a property changes after insertion into a Dictionary or HashSet, the stored hash code no longer matches the bucket, and lookups fail. Treat records as immutable; if you need mutation, a record is the wrong tool.
ToString leaks data
The generated ToString prints every property value. That is convenient for logging and debugging, but it means positional records should not hold secrets, tokens, or personally identifiable information unless you override ToString.
No deep copying or thread safety
A with expression performs a shallow copy. If a record holds a mutable array or collection, the copy shares that reference with the original. Records do not provide thread safety; concurrent mutation of a shared mutable member still requires synchronization.
record struct is a different trade-off
C# 10 introduced record struct, a value type with similar syntax but different semantics. A record struct is copied on assignment and passed by value, which can reduce allocations but changes equality and mutation behavior. Choose record struct only when value-type copying is actually desired; for most DTOs and domain value objects, the reference-type record is the safer default.
Verification steps
Before relying on record semantics in production code, verify the behavior in your project:
- Confirm language version. Records require C# 9 or later;
record structrequires C# 10 or later. Open your project file (.csproj) and check the<LangVersion>element. If it is absent, the SDK default applies, which depends on your target framework. Rundotnet buildfrom the project directory to confirm the syntax compiles. - Assert value equality. Add a test that constructs two identical positional records and asserts
==istrueandReferenceEqualsisfalse. - Assert copy semantics. Verify that
a with { Amount = 12m }returns a new value whilea.Amountremains unchanged. - Test collection equality. If your record contains a
List<T>or array, write a test that confirms whether equality is reference-based, and document or fix the behavior. - Review ToString output. Check that generated
ToStringdoes not expose sensitive fields.
None of these checks require external tooling; standard unit tests and a project-file inspection are sufficient.
When not to use a record
Records are not a universal replacement for classes. Avoid them when:
- The type has significant behavior and identity beyond its data (use a class).
- You need mutable state that participates in hash-based collections (use a class with careful equality, or redesign).
- You rely on inheritance hierarchies where base and derived instances must compare equal (records compare runtime types).
- The payload contains secrets or PII and you cannot override
ToString.
For small, immutable data carriers—DTOs, messages, value objects—records remove boilerplate and make equality intent explicit. The key is to understand what the compiler generates, verify it against your requirements, and respect the shallow-copy and runtime-type boundaries.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.