C# Record Equality vs Class Equality Explained
c# record equality vs class equality: Understand how C# records and classes differ in equality behavior, when value semantics apply, and how to customize equality for...
When you compare two objects in C#, the result depends on whether the type is a class or a record. A class uses reference equality by default: two variables are equal only if they point to the same object in memory. A record, introduced in C# 9, uses value equality: two records are equal if their public properties have the same values. This difference is the core of the c# record equality vs class equality question, and it affects how you design data models, write tests, and implement domain logic.
How Classes Handle Equality by Default
For a regular class, Equals and == compare references, not data. Consider this simple class:
public class Person { public string Name { get; set; } public int Age { get; set; } }
Two instances with identical property values are not equal:
var p1 = new Person { Name = "Alice", Age = 30 }; var p2 = new Person { Name = "Alice", Age = 30 }; Console.WriteLine(p1 == p2); // False Console.WriteLine(p1.Equals(p2)); // False
The default Object.Equals implementation checks whether the two references point to the same object. That is often not what you want for objects that represent data, such as a person, a product, or an order. To make classes compare by value, you must override Equals and GetHashCode manually, and optionally implement IEquatable<T>.
How Records Implement Value Equality
A record gives you value equality out of the box. The compiler synthesizes Equals, GetHashCode, and ==/!= operators based on the public properties defined in the record's primary constructor. For example:
public record Person(string Name, int Age);
Now the same comparison yields true:
var p1 = new Person("Alice", 30); var p2 = new Person("Alice", 30); Console.WriteLine(p1 == p2); // True Console.WriteLine(p1.Equals(p2)); // True
The generated Equals method compares each property using EqualityComparer<T>.Default. This works for simple value types, strings, and even collections if they implement IEquatable or have a proper Equals override. For nested records, the comparison is recursive because each record already has value semantics.
The Role of IEquatable<T> in Both Types
IEquatable<T> is an interface that allows a type to define a strongly typed equality check, avoiding boxing and improving performance. Records implement it automatically. The compiler generates an Equals(T? other) method that checks the runtime type and then compares each field. Classes do not implement it by default, so if you want value equality in a class, you must implement it yourself.
Here is a manual implementation for a class:
public class Person : IEquatable<Person> { public string Name { get; set; } public int Age { get; set; } public bool Equals(Person? other) { if (other is null) return false; if (ReferenceEquals(this, other)) return true; return Name == other.Name && Age == other.Age; } public override bool Equals(object? obj) => Equals(obj as Person); public override int GetHashCode() => HashCode.Combine(Name, Age); }
This is exactly what the record compiler generates, but you have to maintain it manually. Any time you add or remove a property, you must update Equals and GetHashCode. Records eliminate that maintenance burden.
When Reference Equality Still Matters
Reference equality is not always wrong. It is the correct default for objects that have identity, such as a service, a repository, or a configuration manager. Two instances of a Service class are not interchangeable even if they have the same fields, because each instance may hold state like a connection pool or a cache. For such types, value equality would be misleading.
Records are not a universal replacement for classes. They are designed for data-centric types where two objects with the same values represent the same logical entity. If you need reference semantics, you can still use a class, or you can define a record with a class-like behavior by not relying on the generated equality—but that is rarely useful. The choice should be driven by the role the type plays in your application.
Customizing Equality in Records
Records allow you to override the generated equality behavior when needed. For example, you might want to compare only a subset of properties or use a custom comparer. You can override Equals and GetHashCode in a record, but you must be careful to keep the == operator consistent. The compiler generates the == operator based on the Equals method, so overriding Equals affects both.
Consider a record where you want to ignore a timestamp property:
public record Order(int Id, decimal Amount, DateTime CreatedAt) { public virtual bool Equals(Order? other) { return other is not null && Id == other.Id && Amount == other.Amount; } public override int GetHashCode() => HashCode.Combine(Id, Amount); }
Now two orders with the same Id and Amount are equal regardless of CreatedAt. This is useful when the timestamp is metadata rather than part of the logical identity. Note that the record still requires the virtual keyword on the generated Equals to allow overriding.
Performance and Memory Considerations
Value equality has a runtime cost. Comparing two records requires checking every property, which can be more expensive than a single reference comparison. For small data objects with a few fields, the difference is negligible. But if you store records in collections and perform frequent lookups, the GetHashCode implementation becomes important. Records generate a hash code that combines all properties, so the cost grows with the number of properties. For large records, this can affect dictionary operations.
That said, the cost of manually implementing equality is often higher than the runtime cost of using records, because manual implementations are error-prone and can introduce subtle bugs. If you need value semantics, records give you a correct, maintainable implementation with predictable performance. If you need reference semantics, classes avoid the overhead entirely.
Choosing Between Record and Class for Equality
Use a record when your type is a data carrier: DTOs, request/response models, value objects, or entities that are compared by value. Use a class when the type has behavior, mutable state, or identity that should not be compared by value. The decision is not about syntax but about the semantics your domain requires.
A common pattern is to use records for immutable data and classes for mutable state. Records support with expressions for non-destructive mutation, which pairs well with value equality. If you need to compare two snapshots of the same entity over time, records make that comparison trivial. If you need to track object identity across a session, a class with reference equality is more appropriate.
Practical Example: Testing with Records vs Classes
Value equality in records simplifies unit tests. You can assert that a method returns the expected record without writing custom equality logic:
public record User(int Id, string Email); public User GetUser(int id) => new(id, "user@example.com"); [Fact] public void GetUser_ReturnsExpectedUser() { var result = GetUser(1); Assert.Equal(new User(1, "user@example.com"), result); }
With a class, this assertion would fail unless you override Equals. Records make the test intent clear and reduce boilerplate. This is a strong argument for using records in code that crosses boundaries, such as API responses or database mappings, where equality is often needed for comparisons and deduplication.
Compatibility and Language Version Considerations
Records are available in C# 9 and later, and they require .NET 5 or newer. If you are working on an older codebase, you cannot use records without upgrading the language version and target framework. In that case, you must implement value equality manually in classes. The record struct (C# 10) provides value semantics for structs, which can be useful for small, frequently copied data. However, record struct has different equality behavior than record class because structs already have value semantics, but the generated equality is more efficient.
When you upgrade a project to use records, be aware that changing a class to a record changes the equality semantics for existing code. Any code that relied on reference equality will behave differently. This is a breaking change, so review all usages before converting a class to a record.
Final Technical Consideration: Hash Codes and Mutation
One subtle issue with records and value equality is that the generated GetHashCode is based on the current property values. If a record is mutable (you can define records with init or set accessors), changing a property after the record is placed in a hash-based collection changes its hash code. This can break dictionary lookups or HashSet operations. The same problem exists with manually implemented classes, but records make it easier to accidentally create mutable value types because the syntax encourages init but does not enforce immutability.
To avoid this, treat records as immutable. Use init accessors or make properties get-only. If you need to change a value, use the with expression to create a new record. This preserves the integrity of hash-based collections and keeps equality consistent. This is a practical constraint that every developer using records should understand.