Back to Blog
C#

C# Record Equality: How It Works and When to Customize

c# record equality: Understand how C# records implement value-based equality, when the default behavior is sufficient, and how to customize it for nested or mutable data.

C# recordsvalue equalityGetHashCodeEqualityContractrecord customization
Two identical record boxes being compared with a checkmark, symbolizing value-based equality in C#.

C# records give you value-based equality without writing boilerplate. When you declare a record, the compiler generates an Equals method, GetHashCode, and operators == and != that compare the record's public properties by value. That behavior is convenient, but it is not always what you want. This article explains how c# record equality actually works under the hood, where it falls short, and how to take control when your data model requires it.

How Records Implement Equality

Consider a simple positional record:

public record Person(string FirstName, string LastName);

The compiler synthesizes an Equals method that checks whether the other object is a Person and then compares each positional parameter using EqualityComparer<T>.Default. It also generates a matching GetHashCode that combines the hash codes of all properties. The == and != operators delegate to that Equals method.

That means two records are equal if they have the same type and all their properties are equal. The comparison is shallow in the sense that it uses the default equality comparer for each property. For simple value types like strings and integers, that gives you intuitive value semantics.

Comparing Records with Classes

A regular class uses reference equality by default. Two class instances with identical property values are not equal unless you override Equals and GetHashCode manually. Records remove that manual work. The tradeoff is that records are designed to be immutable, and their equality behavior assumes that the data does not change after creation.

If you need reference semantics for a mutable object, a class is the better choice. If you need value semantics and are willing to treat instances as immutable data, a record saves you from writing repetitive comparison code.

Customizing Record Equality

The default record equality uses all public properties. Sometimes you want to exclude certain fields or apply a different comparison rule. You can override the generated members manually.

For example, suppose you want to compare only the Id property of an entity record:

public record Entity { public int Id { get; init; } public string Name { get; init; } public virtual bool Equals(Entity? other) { return other is not null && Id == other.Id; } public override int GetHashCode() => Id.GetHashCode(); }

When you override Equals, you must also override GetHashCode to keep the contract that equal objects have equal hash codes. The compiler still generates the == and != operators, but they call your Equals method, so the custom logic is used consistently.

Records with Collections and Nested Objects

Record equality compares each property using the default equality comparer. If a property is a list or a dictionary, that comparer uses reference equality for the collection itself. Two records with identical list contents but different list instances are not equal.

public record Order(int Id, List<string> Items); var a = new Order(1, new List<string> { "apple", "banana" }); var b = new Order(1, new List<string> { "apple", "banana" }); Console.WriteLine(a == b); // False

To get structural equality for collections, you need to override Equals and compare the collection contents explicitly. Alternatively, use a collection type that implements value equality, such as ImmutableArray<T> or a custom comparer. This is a common source of confusion because developers expect deep equality from records.

Performance Considerations

The generated GetHashCode computes a hash from every property every time it is called. For records with many properties or properties that are expensive to hash, this can become a measurable cost in hash-based collections like Dictionary or HashSet. The compiler does not cache the hash code automatically.

If a record is used frequently as a dictionary key and has a large number of properties, consider overriding GetHashCode to compute a hash from a subset of properties that uniquely identify the record. You can also cache the hash code in a private field if the record is immutable and the hash is expensive to compute. Keep in mind that caching only works if the record is truly immutable; otherwise, the cached value becomes stale.

Common Pitfalls with Record Equality

One subtle issue arises with inheritance. A derived record inherits the equality contract of its base, but the generated Equals checks the runtime type using the EqualityContract property. Two records of different derived types are never equal, even if they share all property values. That is usually correct, but it can surprise developers who expect cross-type equality.

Another pitfall is mutable records. You can declare a record with init-only properties, but you can also use set accessors. If you mutate a record after it has been added to a hash-based collection, its hash code changes and the collection becomes corrupted. Treat records as immutable values to avoid this.

When to Use Record Equality vs Manual Implementation

Use the default record equality when your record is immutable, all properties should participate in equality, and the properties themselves have sensible value semantics. That covers DTOs, event messages, and value objects.

Override equality when you need to ignore certain fields, compare nested collections structurally, or apply a domain-specific rule. Manual implementation gives you full control but adds maintenance burden. Weigh the cost of custom code against the benefit of correct behavior for your specific data model.

For most cases, the default c# record equality works well. The key is to understand what the compiler generates and to recognize when your data model requires a different comparison strategy.

c# record equality: Practical Usage and Code Examples | RYUSLOG DEV