C# Records

A C# record is a data-focused type that gives you useful behavior automatically: readable printing, value-based equality, deconstruction, and non-destructive copying with with. Records matter because many programs pass around facts, settings, messages, and results where the data values are more important than object identity. Instead of writing lots of boilerplate, you declare the shape of the data and let the compiler generate the repetitive parts.

Overview: How Records Work

A record can be a reference type or a value type. record and record class declare a reference type, while record struct declares a value type. The most common form is the positional record, such as public record Person(string FirstName, string LastName);. That single line creates a type with public init-only properties, a constructor that accepts those values, value-based equality, a useful ToString, and a Deconstruct method.

The main idea is value semantics. With ordinary classes, two separate objects are usually equal only if they are the same object reference, unless you override equality yourself. With records, two record values of the same runtime record type compare equal when their included data members compare equal. This makes records a strong fit for immutable data transfer objects, configuration snapshots, API results, events, commands, and small domain values that are identified by their contents.

Records are not automatically deeply immutable. A positional record class creates init-only properties, so you cannot assign a new value to those properties after construction. However, if a property refers to a mutable object such as List<string>, the list itself can still be changed. The record protects the property assignment, not the entire object graph. This is one of the most important record gotchas.

The compiler generates several members for records. These include equality members, GetHashCode, a printable ToString, and a protected copy constructor for record classes. The with expression uses that copying support to create a new record from an existing one while changing selected properties. For example, updated = original with { LastName = "Byron" } keeps the old record unchanged and returns a new one with a different last name.

Record classes can inherit from other record classes, but they cannot inherit from ordinary classes except object. Record structs are structs, so they do not support inheritance. Use a record class when you want reference-type behavior with generated value equality. Use a record struct when the value is small, copy-friendly, and should behave like a value type.

Syntax

public record Person(string FirstName, string LastName);

public record class Ticket
{
    public required string Id { get; init; }
    public required string CustomerName { get; init; }
}

public readonly record struct Point(int X, int Y);
Form Meaning
record Declares a record class by default. It is a reference type with value-based equality.
record class Explicitly declares a record reference type. This is clearer when comparing with record struct.
record struct Declares a record value type. Assignment copies the value, like other structs.
readonly record struct Declares an immutable record struct whose generated properties are read-only.
(string FirstName, string LastName) A primary constructor. For records, the parameters become public properties and participate in generated members.
with Creates a copy of a record while assigning new values to selected init-settable properties.

Examples

Example 1: Value Equality and With Expressions

using System;

public record Person(string FirstName, string LastName);

class Program
{
    static void Main()
    {
        Person ada1 = new Person("Ada", "Lovelace");
        Person ada2 = new Person("Ada", "Lovelace");
        Person renamed = ada1 with { LastName = "Byron" };

        Console.WriteLine(ada1);
        Console.WriteLine(ada2);
        Console.WriteLine(ada1 == ada2);
        Console.WriteLine(renamed);
        Console.WriteLine(ada1);
    }
}

Output:

Person { FirstName = Ada, LastName = Lovelace }
Person { FirstName = Ada, LastName = Lovelace }
True
Person { FirstName = Ada, LastName = Byron }
Person { FirstName = Ada, LastName = Lovelace }

ada1 and ada2 are different objects, but records compare their values, so ada1 == ada2 is True. The with expression creates renamed by copying ada1 and changing only LastName. The original record is unchanged.

Example 2: A Realistic Order Line Record

using System;
using System.Globalization;

public record OrderLine(string Sku, int Quantity, decimal UnitPrice)
{
    public decimal LineTotal => Quantity * UnitPrice;

    public override string ToString()
    {
        return $"{Sku} x {Quantity}";
    }
}

class Program
{
    static void Main()
    {
        OrderLine line = new OrderLine("BOOK-1", 3, 14.99m);
        OrderLine largerLine = line with { Quantity = 5 };

        Console.WriteLine(line);
        Console.WriteLine($"Total: {line.LineTotal.ToString("0.00", CultureInfo.InvariantCulture)}");
        Console.WriteLine(largerLine);
        Console.WriteLine($"Total: {largerLine.LineTotal.ToString("0.00", CultureInfo.InvariantCulture)}");
    }
}

Output:

BOOK-1 x 3
Total: 44.97
BOOK-1 x 5
Total: 74.95

This record stores the data needed for one order line and adds a calculated property. LineTotal is not stored separately, so it cannot become stale. Overriding ToString is allowed; records give a generated version, but you can replace it when your program needs a more compact display.

Example 3: Record Structs for Small Values

using System;

public readonly record struct Measurement(double Value, string Unit)
{
    public override string ToString()
    {
        return $"{Value:0.##} {Unit}";
    }
}

class Program
{
    static void Main()
    {
        Measurement first = new Measurement(2.5, "m");
        Measurement second = first;
        Measurement third = first with { Value = 3.0 };

        Console.WriteLine(first);
        Console.WriteLine(second);
        Console.WriteLine(first == second);
        Console.WriteLine(third);
    }
}

Output:

2.5 m
2.5 m
True
3 m

Measurement is a record struct, so it is a value type. The assignment to second copies the measurement value, and generated equality still compares the contents. The readonly modifier prevents accidental mutation after construction.

How Records Work Step by Step

  1. The compiler reads the record declaration and creates the requested type: a class for record or record class, and a struct for record struct.
  2. For a positional record, the primary constructor parameters become public properties. In a record class, they are init-only properties. In a readonly record struct, they are get-only properties.
  3. The compiler generates equality logic that compares record data. For record classes, it also includes runtime record type checks so inherited records do not accidentally compare equal to base records with the same fields.
  4. The compiler generates GetHashCode consistently with equality. This matters when records are used as keys in dictionaries or stored in hash sets.
  5. The compiler generates a readable ToString that prints the record name and public printable members unless you override it.
  6. For record classes, with uses a compiler-generated copy operation, then applies the object initializer assignments. For record structs, with copies the value and applies the changes to the copy.
  7. If you add your own members, such as methods or calculated properties, they live alongside the generated members. You can override generated behavior when the default is not right for the type.

Common Mistakes

Assuming Records Are Deeply Immutable

using System;
using System.Collections.Generic;

public record Team(string Name, List<string> Members);

class Program
{
    static void Main()
    {
        Team team = new Team("Core", new List<string> { "Ava" });
        team.Members.Add("Noah");

        Console.WriteLine(team.Members.Count);
    }
}

Output:

2

This compiles and runs because Members is init-only, but the list object stored inside the property is mutable. The property cannot be assigned to a different list after construction, but the existing list can still be changed. Prefer immutable collection types or expose read-only data when the record should represent a stable snapshot.

using System;
using System.Collections.Generic;

public record Team(string Name, IReadOnlyList<string> Members);

class Program
{
    static void Main()
    {
        string[] members = { "Ava", "Noah" };
        Team team = new Team("Core", Array.AsReadOnly(members));

        Console.WriteLine(team.Name);
        Console.WriteLine(team.Members.Count);
    }
}

Output:

Core
2

Using a Record for an Entity With Identity

using System;

public record BankAccount(string AccountNumber, decimal Balance);

class Program
{
    static void Main()
    {
        BankAccount first = new BankAccount("A100", 50m);
        BankAccount second = new BankAccount("A100", 50m);

        Console.WriteLine(first == second);
    }
}

Output:

True

This may be wrong for a real bank account. Two account objects with the same visible values might still represent different tracked entities, lifecycle states, audit histories, or database rows. When object identity matters, use a class and write equality deliberately only if the domain calls for it.

using System;

public class BankAccount
{
    public BankAccount(string accountNumber, decimal balance)
    {
        AccountNumber = accountNumber;
        Balance = balance;
    }

    public string AccountNumber { get; }
    public decimal Balance { get; private set; }
}

class Program
{
    static void Main()
    {
        BankAccount first = new BankAccount("A100", 50m);
        BankAccount second = new BankAccount("A100", 50m);

        Console.WriteLine(ReferenceEquals(first, second));
    }
}

Output:

False

Best Practices

  • Use records for data-shaped types where equality should be based on contents.
  • Use record class for larger data transfer objects, API models, commands, events, and immutable snapshots.
  • Use readonly record struct for small values that should copy cheaply and behave like value types.
  • Do not assume record classes are value types. They are reference types unless you write record struct.
  • Prefer immutable or read-only members inside records. A record with mutable lists or dictionaries can still change after construction.
  • Be careful using mutable records as dictionary keys. If a value used by equality changes, hash-based collections can no longer find the key reliably.
  • Use with when you want non-destructive updates instead of mutating an existing object.
  • Override ToString only when the generated diagnostic format is not suitable for your output.
  • Avoid records for services, controllers, repositories, UI widgets, open connections, and domain entities whose identity is more important than their values.

Practice Exercises

  1. Create a positional record named Book with Title, Author, and Year. Create two equal books and print the result of ==.
  2. Create an Address record and use a with expression to make a copy with a different postal code while leaving the original unchanged.
  3. Create a readonly record struct Money with Amount and Currency. Add a ToString override that prints a formatted amount and currency code.

Summary

  • Records are data-focused types with compiler-generated equality, hashing, printing, deconstruction, and copying support.
  • record and record class create reference types; record struct creates a value type.
  • Positional records turn constructor parameters into public properties that participate in generated equality.
  • with expressions create modified copies without changing the original record.
  • Records provide shallow immutability by default, not deep immutability of referenced objects.
  • Use records when values define equality, and use ordinary classes when identity and lifecycle are central.