C# 9 Records and Value-Based Equality Explained
This article explores the record type and value-based equality introduced in C# 9.0. You will learn what records are, how they differ from standard classes and structs, how value-based equality functions under the hood, and how features like non-destructive mutation streamline data modeling in modern .NET applications.
What Are Records in C# 9?
Introduced in C# 9, a record is a reference type that
provides a concise syntax for defining immutable data models. While
traditional classes are primarily designed around object-oriented
encapsulation and mutable state, records are intended to treat data as
pure values.
You can define a record using positional syntax in a single line:
public record Person(string FirstName, string LastName);Under the hood, the C# compiler generates:
- An immutable reference type.
- Public init-only properties for each positional parameter.
- A primary constructor to populate those properties.
- Value-based equality implementations (
Equals,GetHashCode, and operator overloads for==and!=). - A deconstructor (
Deconstruct) method. - A copy constructor and cloning mechanism for non-destructive mutation.
Understanding Value-Based Equality
The defining characteristic of records is value-based equality.
By default, C# reference types (classes) use reference equality. Two instances of a class are considered equal only if they point to the exact same location in memory, even if all their property values match.
In contrast, value types (structs) use value
equality, but standard structs rely on reflection via
ValueType.Equals, which introduces performance
overhead.
Records bridge this gap: they are reference types, but the compiler automatically generates strongly typed equality methods that compare the values of their properties instead of memory addresses.
Comparing Classes and Records
Consider how equality behaves in a standard class versus a record:
// Standard Class
public class ClassPerson
{
public string Name { get; set; }
}
var c1 = new ClassPerson { Name = "Alice" };
var c2 = new ClassPerson { Name = "Alice" };
Console.WriteLine(c1 == c2); // Outputs: False (different memory addresses)
// Record
public record RecordPerson(string Name);
var r1 = new RecordPerson("Alice");
var r2 = new RecordPerson("Alice");
Console.WriteLine(r1 == r2); // Outputs: True (same values)For two record instances to be considered equal:
- Both instances must be of the same runtime type.
- Every field and property in both instances must be equal according to their own equality rules.
Built-In Equality Members
When you declare a record, the compiler synthesizes the following equality contracts automatically:
IEquatable<T>: Provides high-performance, type-safe equality checking without boxing.virtual bool Equals(object? obj)andvirtual bool Equals(R? other): Overridden methods to ensure polymorphic equality works correctly across inheritance hierarchies.GetHashCode(): Overridden so that instances with matching property values yield identical hash codes, making records safe to use as keys in hash tables and dictionaries.==and!=operators: Overloaded to invoke the value-equality logic instead of standard reference comparison.
Non-Destructive
Mutation with the with Expression
Because records are designed to be immutable, modifying a record
requires creating a copy with updated values. C# 9 introduced the
with expression to support this non-destructive
mutation:
var original = new Person("John", "Doe");
var updated = original with { LastName = "Smith" };
Console.WriteLine(original == updated); // Outputs: FalseThe with expression uses a compiler-synthesized copy
constructor to duplicate the existing instance while altering only the
specified properties.
When to Use Records
Records are ideal when:
- You need data transfer objects (DTOs) or API request/response models.
- You want immutable data models to prevent unintended side effects.
- You need objects compared by their values rather than their identity.
- You want concise, readable declarations for data-centric structures.