Records vs Classes: Choosing the Right Type in C#
C# 9 introduced records as a first-class language feature, and C# 10 followed with record structs. With three similar-sounding options — class, record class, and record struct — choosing the right one requires understanding what records actually give you.
What Makes a Record Different
A record is not a fundamentally new kind of type. It is a class (or struct) with compiler-generated members that make it behave like a value. The compiler generates:
- Value-based equality:
Equals,GetHashCode, and==/!=operators that compare property values, not references. - A
ToStringoverride that prints all properties. - A
withexpression for non-destructive mutation. - Deconstruction for positional records.
public record Person(string Name, int Age);
var alice = new Person("Alice", 30);
var alsoAlice = new Person("Alice", 30);
Console.WriteLine(alice == alsoAlice); // True — value equality
Console.WriteLine(alice); // Person { Name = Alice, Age = 30 }
With a standard class, alice == alsoAlice would be false because they are different object references.
Positional vs Nominal Syntax
Records support two declaration styles. Positional syntax uses a parameter list:
public record Coordinate(double Latitude, double Longitude);
This generates a constructor, init-only properties, and a Deconstruct method. Nominal syntax uses a traditional body:
public record Coordinate
{
public required double Latitude { get; init; }
public required double Longitude { get; init; }
}
Both produce similar results. Positional syntax is more concise; nominal syntax gives you finer control over property definitions.
The With Expression
Records support non-destructive mutation through the with keyword:
var original = new Person("Alice", 30);
var updated = original with { Age = 31 };
Console.WriteLine(original); // Person { Name = Alice, Age = 30 }
Console.WriteLine(updated); // Person { Name = Alice, Age = 31 }
The with expression creates a shallow copy with the specified properties changed. The original remains untouched. This is particularly useful in functional-style code and in state management patterns like Redux.
Record Structs
C# 10 added record struct, which combines value-type semantics with the record features:
public record struct Money(decimal Amount, string Currency);
Unlike record class, a record struct has mutable properties by default. To make them immutable, use the readonly modifier:
public readonly record struct Money(decimal Amount, string Currency);
Choose record struct for small, frequently-allocated types where you want value equality without heap allocations.
When to Use Each
Use a class when:
- Identity matters more than data (entities with a database ID)
- You need reference semantics and mutability
- You have complex inheritance hierarchies
- The object has significant behaviour alongside its data
Use a record class when:
- The type primarily represents data
- Two instances with the same values should be considered equal
- You want immutability by default
- You need the
withexpression for updates - The type serves as a DTO, event, or message
Use a record struct when:
- The type is small (Microsoft suggests roughly 16 bytes or less)
- You want to avoid heap allocations
- You want value equality without writing boilerplate
Inheritance with Records
Record classes support inheritance, with equality working correctly across the hierarchy:
public record Animal(string Name);
public record Dog(string Name, string Breed) : Animal(Name);
Animal a = new Dog("Rex", "Labrador");
Animal b = new Dog("Rex", "Labrador");
Console.WriteLine(a == b); // True — same type and same values
The compiler uses an EqualityContract property to ensure that a Dog never equals an Animal, even if the shared properties match. Record structs do not support inheritance, following the same restriction as regular structs.
Performance Considerations
Records generate GetHashCode and Equals implementations that examine every property. For records with many properties or properties containing large collections, this can be expensive. If you use records as dictionary keys or in hash sets, be mindful of the cost.
The with expression performs a shallow copy, meaning reference-typed properties share the same object. If your record contains a List<T>, both the original and the copy point to the same list.
Practical Guidance
Records have become the default choice for data-carrying types in modern C#. DTOs, API responses, configuration objects, domain events, and value objects are all natural fits. If you find yourself writing a class with nothing but properties and then manually implementing Equals and GetHashCode, you almost certainly want a record instead.