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:

Example.cs
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:

Example.cs
public record Coordinate(double Latitude, double Longitude);

This generates a constructor, init-only properties, and a Deconstruct method. Nominal syntax uses a traditional body:

Example.cs
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:

Example.cs
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:

Example.cs
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:

Example.cs
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:

Use a record class when:

Use a record struct when:

Inheritance with Records

Record classes support inheritance, with equality working correctly across the hierarchy:

Example.cs
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.