# C# Records: When and How to Use Them

> Records in C# provide value equality, immutability, and concise syntax. But should you use them everywhere? Here is when records shine and when they do not.

Published: 2026-07-04. Last updated: 2026-07-14. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/csharp-records-when-how

Use C# records for data carriers: DTOs, API responses, CQRS commands and queries, domain events, configuration objects, and simple value objects.
Keep regular classes for domain entities, EF Core entities, and anything with frequently mutated state.
Records give you value equality, immutability, and concise syntax for free, but in the wrong place they trade boilerplate for subtle bugs.

You can replace 30 lines of equality boilerplate with a single line of C#.
That's the pitch for records, and it's real.

Here's when records shine, and when they don't.

## What Are Records?

A record is a C# type with compiler-generated value equality, so two records with the same property values are equal.
Records are reference types (or value types with `record struct`) that provide:
- **Value equality** - the compiler synthesizes equality member by member, so two records with the same property values are equal
- **Immutability** - positional `record class` properties are init-only by default (a plain `record struct` stays mutable unless you mark it `readonly`)
- **Concise syntax** - positional parameters generate init-only properties, a primary constructor, and a `Deconstruct` method

```csharp
// Record with positional syntax
public record OrderPlaced(Guid OrderId, Guid CustomerId, decimal Total);

// Equivalent to ~30 lines of a regular class
```

Records arrived in C# 9; `record struct` and `readonly record struct` followed in C# 10.

One thing to keep straight: a positional record is not the same as a C# 12 primary constructor on a class.
`public class Foo(int x)` gives you a constructor parameter, but no value equality, no `with` expression, no formatted `ToString`, and no generated properties.
Only `record` and `record struct` synthesize those members for you.
The primary constructor guide covers capture and initialization in regular classes, while struct versus class covers the allocation and copying tradeoffs behind choosing a value type.

## Record vs Class vs Struct

```csharp
// Record - reference type, value equality
public record Money(decimal Amount, string Currency);

// Class - reference type, reference equality
public class MoneyClass
{
    public decimal Amount { get; set; }
    public string Currency { get; set; }
}

// Record struct - value type, value equality
public record struct Point(double X, double Y);
```

```csharp
var a = new Money(100, "USD");
var b = new Money(100, "USD");

a == b;        // true (value equality)
a.Equals(b);   // true

var c = new MoneyClass { Amount = 100, Currency = "USD" };
var d = new MoneyClass { Amount = 100, Currency = "USD" };

c == d;        // false (reference equality)
c.Equals(d);   // false
```

The compiler generates `Equals`, `GetHashCode`, `==`, and `!=` for records, plus a hidden copy constructor and a `Clone` method.
The equality members are what make value comparisons work; the copy constructor is what powers non-destructive mutation with `with`.
I explored this in [**records and non-destructive mutation**](https://milanjovanovic.tech/blog/records-anonymous-types-non-destructive-mutation).

## When to Use Records

### DTOs and API Responses

Records are perfect for data transfer objects:

```csharp
public record OrderResponse(
    Guid Id,
    string CustomerName,
    decimal Total,
    string Status,
    DateTime CreatedAt,
    List<OrderItemResponse> Items);

public record OrderItemResponse(
    string ProductName,
    int Quantity,
    decimal UnitPrice);
```

### CQRS Commands and Queries

```csharp
public sealed record PlaceOrderCommand(
    Guid CustomerId,
    List<OrderItemRequest> Items) : ICommand<Guid>;

public sealed record GetOrderByIdQuery(Guid OrderId) : IQuery<OrderResponse>;
```

### Domain Events

```csharp
public sealed record OrderPlacedDomainEvent(Guid OrderId) : IDomainEvent;

public sealed record OrderCancelledDomainEvent(
    Guid OrderId, string Reason) : IDomainEvent;
```

### Configuration Objects

```csharp
public record DatabaseOptions(
    string ConnectionString,
    int MaxRetryCount,
    int CommandTimeout);

public record JwtOptions(
    string SecretKey,
    string Issuer,
    string Audience,
    int ExpirationMinutes);
```

### Value Objects (Lightweight)

For simple [**value objects**](https://milanjovanovic.tech/blog/value-objects-in-dotnet-ddd-fundamentals), records are a natural fit.
One catch: you can't add validation to a positional record's generated constructor.
Declare the properties yourself when you need to validate:

```csharp
public sealed record Email
{
    public string Value { get; }

    public Email(string value)
    {
        if (string.IsNullOrWhiteSpace(value) || !value.Contains('@'))
        {
            throw new ArgumentException("Invalid email", nameof(value));
        }

        Value = value.Trim().ToLowerInvariant();
    }
}

public sealed record DateRange
{
    public DateOnly Start { get; }
    public DateOnly End { get; }

    public DateRange(DateOnly start, DateOnly end)
    {
        if (end < start)
        {
            throw new ArgumentException("End must be after start");
        }

        (Start, End) = (start, end);
    }

    public int Days => End.DayNumber - Start.DayNumber;
}
```

You still get value equality and the formatted `ToString` for free, but now invalid instances are impossible to construct.
(These properties are get-only rather than init-only, so a `with` expression can't reassign them and skip validation.)
If you're unsure whether something is a value object or an entity, my article on **entities vs value objects** covers the distinction.

## When NOT to Use Records

### Domain Entities

Entities have identity-based equality, not value-based:

```csharp
// ❌ Don't use records for entities
public record Order(Guid Id, string Customer, decimal Total);

// Two orders with the same data are NOT the same order
var order1 = new Order(Guid.NewGuid(), "John", 100);
var order2 = new Order(Guid.NewGuid(), "John", 100);
// Records would compare ALL properties, but we want ID-only equality
```

Use a regular class:

```csharp
// ✅ Use a class for entities
public class Order : Entity
{
    public Guid CustomerId { get; private set; }
    public decimal Total { get; private set; }
}
```

### Mutable State

Records default to immutability. If you need to mutate state:

```csharp
// Awkward - creates a new object every time
var order = order with { Status = OrderStatus.Shipped };
var order2 = order with { Total = newTotal };
```

For frequently mutated objects, a regular class is better.

### EF Core Entities

EF Core can materialize a record through its positional constructor, so a record entity will compile and even query.
The friction starts after materialization, because tracked entities are built to be mutated:

```csharp
// ❌ Works, but fights the change tracker
public record Order(Guid Id, string Customer);

// ✅ Regular class for EF Core entities
public class Order
{
    public Guid Id { get; set; }
    public string Customer { get; set; }
    public List<LineItem> LineItems { get; set; } = [];
}
```

Records are fine as keyless read models and projection targets, where you only ever read the data.
But change tracking, lazy loading proxies, and identity semantics all assume mutable classes with reference equality, so keep tracked entities as classes.
Value objects mapped as **owned types** are the exception.

## Record Features

### With Expressions

Create copies with modifications:

```csharp
var original = new Money(100, "USD");
var doubled = original with { Amount = 200 };
// doubled is Money(200, "USD")
```

A `with` expression never mutates the source.
It clones the record, applies the changes you list, and hands back a new instance, leaving the original untouched.

![A with expression copies the original Money record, applies the changed Amount to produce a new instance, and leaves the original record unchanged](https://milanjovanovic.tech/blogs/articles/csharp-records-when-how/with-expression-copy.png)

### Deconstruction

```csharp
var order = new OrderPlaced(Guid.NewGuid(), Guid.NewGuid(), 99.99m);

var (orderId, customerId, total) = order;
```

### Inheritance

```csharp
public record Shape(double Area);
public record Circle(double Radius) : Shape(Math.PI * Radius * Radius);
public record Rectangle(double Width, double Height) : Shape(Width * Height);
```

Works with **pattern matching**:

```csharp
string Describe(Shape shape) => shape switch
{
    Circle c => $"Circle with radius {c.Radius}",
    Rectangle r => $"Rectangle {r.Width}x{r.Height}",
    _ => $"Shape with area {shape.Area}"
};
```

### ToString

Records auto-generate a useful `ToString`:

```csharp
var money = new Money(100, "USD");
Console.WriteLine(money);
// Output: Money { Amount = 100, Currency = USD }
```

### Record Classes vs Record Structs

```csharp
// Reference type (heap allocated)
public record Money(decimal Amount, string Currency);

// Value type (no separate heap allocation, good for small types)
public record struct Point(double X, double Y);

// Readonly record struct (fully immutable value type)
public readonly record struct Color(byte R, byte G, byte B);
```

The three record flavors share value equality but differ in where they live and whether they are immutable.

![Map of the three record flavors: record class is a reference type with init-only properties, record struct is a value type with mutable properties, and readonly record struct is a value type with init-only properties, all with value equality](https://milanjovanovic.tech/blogs/articles/csharp-records-when-how/record-types-map.png)

Use `record struct` for small, frequently created types where you want to avoid a separate heap allocation.
Remember that a plain `record struct` has mutable `get; set;` properties; reach for `readonly record struct` when you want the value semantics without mutation.

## The Collection Gotcha

Value equality compares each member with `EqualityComparer<T>.Default`.
For a collection property like `List<T>`, that default comparer falls back to reference equality, because `List<T>` doesn't override `Equals`.
So two records with identical-looking lists are not equal:

```csharp
public record Basket(List<string> Items);

var a = new Basket(["apple", "banana"]);
var b = new Basket(["apple", "banana"]);

a == b;   // false - different List instances, compared by reference
```

The synthesized `Equals` compares the two `List<string>` references, not their contents.
This bites in DTOs and events that carry lists (like the `Items` list earlier).
If you need content equality, expose the collection through a type with structural equality (such as an immutable array wrapper) or override `Equals` and `GetHashCode` yourself.

## Records in Practice

A typical [**CQRS**](https://milanjovanovic.tech/blog/cqrs-pattern-the-way-it-should-have-been-from-the-start) application uses records extensively:

```csharp
// Command
public sealed record CreateProductCommand(
    string Name,
    string Description,
    decimal Price,
    string Category) : ICommand<Guid>;

// Query
public sealed record GetProductsQuery(
    string? Category,
    int Page,
    int PageSize) : IQuery<PagedList<ProductResponse>>;

// Response
public sealed record ProductResponse(
    Guid Id,
    string Name,
    string Description,
    decimal Price,
    string Category);

// Domain event
public sealed record ProductCreatedDomainEvent(Guid ProductId) : IDomainEvent;

// Error
public sealed record Error(string Code, string Description);
```

## Key Takeaways

Use **records** for:

- DTOs and API responses
- Commands and queries
- Domain events
- Value objects
- Configuration objects

Use **classes** for:

- Domain entities (identity-based equality)
- EF Core entities
- Objects with frequently mutated state
- Services and handlers with complex behavior

Records give you value equality, immutability, and concise syntax for free. Use them for data carriers - commands, queries, events, DTOs. Use classes for entities with identity and behavior.

Thanks for reading, and stay awesome!

---

## Frequently asked questions

### What is the difference between a record and a class in C#?

A record is a reference type with compiler-generated value equality, meaning two records with the same property values are equal. A class uses reference equality by default. Records also get init-only properties, with-expressions, deconstruction, and a formatted ToString for free.

### When should you use records in C#?

Use records for data carriers: DTOs, API responses, CQRS commands and queries, domain events, configuration objects, and simple value objects. Anywhere the data defines the identity of the object, records are a great fit.

### Should I use records for EF Core entities?

Generally no. Entities have identity-based equality and mutable state, which conflicts with the value equality and immutability that records provide. Records work well for value objects mapped as owned or complex types, but keep entities as classes.

### What is the difference between record and record struct?

A record (or record class) is a reference type allocated on the heap. A record struct is a value type, so small instances avoid heap allocation entirely. Use readonly record struct for small, immutable types like coordinates or colors.

### Are C# records immutable?

Positional records are immutable by default because their properties are init-only. However, you can declare mutable properties on a record, and record structs are mutable unless marked readonly, so immutability is a default rather than a guarantee.
