C# records are a language feature that lets you declare types that are primarily about holding data, and the compiler generates a bunch of boilerplate code for you.

Let’s see what that looks like in practice. Imagine we need to represent a Product in an e-commerce system.

public record Product(int Id, string Name, decimal Price);

That single line of code, using the record keyword, automatically gives us:

  • An Id property of type int.
  • A Name property of type string.
  • A Price property of type decimal.

All of these properties are init-only by default, meaning they can only be set during object creation and cannot be changed afterward. This makes Product an immutable type.

But wait, there’s more! The compiler also generates:

  • An Equals method that compares two Product objects based on the values of all their properties. This is crucial for value equality – two products are the same if their ID, Name, and Price are the same, not just if they are the same instance in memory.
  • An GetHashCode method that’s consistent with the Equals method, essential for using records in hash-based collections like Dictionary or HashSet.
  • A ToString method that provides a concise, readable representation of the record’s data, like Product { Id = 1, Name = "Laptop", Price = 1200.00m }.
  • Support for with expressions, which allow you to create a new instance of the record with some properties modified, while preserving the immutability of the original.

Here’s with in action:

var originalProduct = new Product(1, "Laptop", 1200.00m);
var updatedProduct = originalProduct with { Price = 1150.00m };

Console.WriteLine(originalProduct); // Output: Product { Id = 1, Name = "Laptop", Price = 1200.00m }
Console.WriteLine(updatedProduct);  // Output: Product { Id = 1, Name = "Laptop", Price = 1150.00m }

This with expression is a powerful way to work with immutable data. Instead of modifying the originalProduct in place (which isn’t allowed anyway due to init-only properties), we create a brand new updatedProduct that is a copy of the original but with a different Price. This pattern is fundamental in functional programming and helps prevent side effects.

Records are particularly well-suited for Data Transfer Objects (DTOs) and representing immutable value objects. For DTOs, immutability means that once a DTO is created and passed around, you can be certain its contents haven’t been accidentally modified by some other part of the system. This makes debugging much easier and reasoning about your code simpler.

Consider a scenario where you’re passing data between different layers of your application. A record DTO ensures that the data received by the consumer is exactly what was sent by the producer.

public record OrderItemDto(int ProductId, string ProductName, int Quantity, decimal UnitPrice);

public void ProcessOrder(List<OrderItemDto> items)
{
    decimal totalCost = 0;
    foreach (var item in items)
    {
        // We can safely access item.Quantity and item.UnitPrice
        // knowing they haven't been changed mid-loop by another thread.
        totalCost += item.Quantity * item.UnitPrice;
    }
    // ... further processing ...
}

The generated Equals method is also incredibly useful when you need to check for value equality.

var product1 = new Product(101, "Keyboard", 75.00m);
var product2 = new Product(101, "Keyboard", 75.00m);
var product3 = new Product(102, "Mouse", 25.00m);

Console.WriteLine(product1.Equals(product2)); // Output: True
Console.WriteLine(product1 == product2);      // Output: True (overloaded equality operator)
Console.WriteLine(product1.Equals(product3)); // Output: False

This automatic value equality is a major time-saver compared to writing it manually for classes.

When you declare a record, you can choose between a primary constructor (like public record Product(...)) or a standard class-like declaration with properties defined inside. The primary constructor syntax is more concise for typical data-holding scenarios. You can also have class records or struct records, and specify mutability if needed, but the default init-only immutability is usually the desired behavior for value objects and DTOs.

The with expression is not just syntactic sugar; it’s a fundamental part of working with immutable data structures. It allows you to express changes in a declarative way, creating new states from old ones without mutation. This is conceptually similar to how data structures work in functional programming languages.

One of the less obvious benefits of records, especially when dealing with complex object graphs or when using them as keys in dictionaries, is the consistency of their generated Equals and GetHashCode methods. You don’t have to remember to override both and ensure they are synchronized. The compiler guarantees this for you, eliminating a common source of bugs related to object equality in collections. This makes records a much safer choice for representing entities that need to be looked up or compared based on their data content.

The next natural step is to explore how records interact with inheritance and how to create more complex data structures using record types.

Want structured learning?

Take the full Csharp course →