Stop Writing Equals and GetHashCode: Use C# Records for Data Models
C# records generate value-based equality, ToString, and init-only properties, but shallow copies and type-sensitive equality can catch you off guard.
20 Dec 2025, 15:18 UTC

The problem: boilerplate equality code
Imagine you are building an API that returns a user-profile DTO. You write a class with five properties, then realize you need value equality so two profiles with the same data compare as equal. Suddenly you are overriding Equals, GetHashCode, ToString, and possibly implementing IEquatable<T>. Every new field means touching all four members. A copy constructor for "change one field and return a new instance" adds even more noise.
What a C# record generates for you
A record is a reference type that the compiler treats as a data carrier. With C# 9 or later, a positional record looks like this:
public sealed record UserProfile(string Email, string DisplayName, string TimeZone);
That single line produces init-only properties, a primary constructor, value-based equality, GetHashCode, ToString, and a Deconstruct method. Two instances with identical property values are equal under == and Equals, unlike ordinary classes where == checks reference identity by default.
Making copies with with
The with expression creates a new record instance while changing only the properties you specify. This is useful for immutable updates, such as handling a request in a pipeline without mutating shared state.
var original = new UserProfile("[contact removed]", "Alice", "UTC");
var updated = original with { DisplayName = "A. Smith" };
original keeps "Alice"; updated gets "A. Smith". Both are separate instances on the heap.
The shallow-copy trap
with performs a shallow copy. If a property holds a reference type such as List<T>, both the original and the copy point to the same list object. Mutating the list in the copy mutates the original too.
public sealed record Order(string Id, List<string> Items);
var order = new Order("100", new List<string> { "Widget" });
var copy = order with { Id = "101" };
copy.Items.Add("Gadget");
// order.Items now contains "Widget" and "Gadget"
To avoid this, either make collections immutable or explicitly copy nested collections in a custom constructor or factory method.
Equality is type-sensitive
Record equality compares the runtime type as well as field values. If you inherit from a record, a derived instance is never equal to a base instance even when every field matches. This is usually correct for domain models, but it can surprise developers expecting polymorphic equality.
public record Person(string Name);
public record Employee(string Name, string Title) : Person(Name);
var person = new Person("Dana");
var employee = new Employee("Dana", "Engineer");
Console.WriteLine(person.Equals(employee)); // False
If you need identity semantics—where two objects are different even with identical data—use a class instead.
record versus record struct
By default, records are classes allocated on the heap. Starting with C# 10, record struct declares a value-type record. Use record struct for small, short-lived data where stack allocation and value semantics matter. Be aware that mutability and equality rules differ subtly from class records, so do not treat them as interchangeable.
Worked example: verify generated members
Create a console application to inspect what the compiler generates:
dotnet new console -n RecordDemo
cd RecordDemo
Run these commands in a terminal. No special permissions are required. Replace Program.cs with:
using System;
public sealed record Product(string Sku, decimal Price, int Stock);
public class Program
{
public static void Main()
{
var a = new Product("ABC-1", 10.00m, 5);
var b = new Product("ABC-1", 10.00m, 5);
var c = a with { Stock = 6 };
Console.WriteLine($"a == b: {a == b}");
Console.WriteLine($"a == c: {a == c}");
Console.WriteLine($"a.ToString(): {a}");
}
}
Run it from the project directory:
dotnet run
The placeholder Product represents your data model; replace it with your own DTO or domain object. The expected behavior is that a == b is true because records use value equality, while a == c is false because Stock differs.
To see the synthesized methods, open the compiled assembly in a decompiler such as ILSpy or dotPeek and inspect Product. Look for Equals, GetHashCode, PrintMembers, ToString, and Deconstruct. This is a diagnostic step, not a runtime requirement.
Limitations and practical checks
- Shallow copies:
withdoes not clone nested reference types. Check your record's properties; if any are collections or classes, decide whether shared state is acceptable. - Serializers: Some older serializers struggle with init-only setters or primary constructors. Verify
System.Text.JsonorNewtonsoft.Jsonbehavior before exposing records in a public API. - Performance: Compiler-generated equality uses
EqualityComparer<T>.Defaultper field. For records with many fields or expensive comparison logic, benchmark against a hand-written implementation if this code is on a hot path.
A practical way to check your adoption is to write a unit test that copies a record with a List<T> property using with, mutates the list in the copy, and asserts whether the original changed. That result tells you immediately whether you need deep-copy semantics.
When to adopt records
Use records for DTOs, API payloads, configuration objects, and event messages where value equality and immutability simplify reasoning. Keep classes for entities with identity, complex inheritance hierarchies, or mutable state that must remain isolated. Start with a single bounded context, inspect the generated code, and expand only after the shallow-copy and serializer checks pass.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.