Records

This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

A record is a class or struct that the compiler fills in for you: value equality, a readable ToString, a Deconstruct method and support for with expressions. Use one whenever a type’s identity is its data.

record class and record struct

// `record` alone means `record class` -- a reference type.
public record Person(string FirstName, string LastName);

// A value type.
public record struct Point(double X, double Y);

// The usual choice for small immutable value data: init-only properties.
public readonly record struct Coordinate(double Latitude, double Longitude);
record class record struct

Semantics

Reference

Value

Positional members are

init-only properties

mutable properties (unless readonly record struct)

Equals compares

Type + all fields

All fields

Can be null

Yes

No (but Point? can)

Supports inheritance

Yes

No

Allocation

Heap

Inline / stack

Positional Records

The parameter list is a primary constructor. On a record — unlike on a plain class — each parameter also becomes a public property:

public record Employee(string Name, string Department, decimal Salary)
{
    // Add members in a body as usual.
    public bool IsManager => Department == "Management";

    // Extra constructors must chain to the primary one.
    public Employee(string name) : this(name, "Unassigned", 0m) { }

    // Additional properties need an initialiser or `required`.
    public DateOnly? StartDate { get; init; }
}

public static class PositionalDemo
{
    public static void Run()
    {
        var ada = new Employee("Ada", "Engineering", 120_000m) { StartDate = new(2026, 1, 1) };

        Console.WriteLine(ada.Name);          // synthesised property
        Console.WriteLine(ada.IsManager);
        Console.WriteLine(ada.StartDate);
    }
}

A record without a parameter list is also fine when you prefer explicit properties:

public record Settings
{
    public required string Environment { get; init; }
    public int Retries { get; init; } = 3;
    public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
}

What the Compiler Synthesises

What the compiler generates for a positional record: from the single line `public record Person(string First
public record Person2(string FirstName, string LastName);

public static class SynthesisDemo
{
    public static void Run()
    {
        var a = new Person2("Ada", "Lovelace");
        var b = new Person2("Ada", "Lovelace");

        // 1. Value equality -- Equals, GetHashCode, == and != all compare the members.
        Console.WriteLine(a == b);                  // True
        Console.WriteLine(a.Equals(b));             // True
        Console.WriteLine(ReferenceEquals(a, b));   // False -- still two objects
        Console.WriteLine(a.GetHashCode() == b.GetHashCode());   // True

        // 2. A readable ToString built by PrintMembers.
        Console.WriteLine(a);    // Person2 { FirstName = Ada, LastName = Lovelace }

        // 3. Deconstruct matching the positional parameters.
        var (first, last) = a;
        Console.WriteLine($"{first} {last}");

        // 4. A copy constructor and Clone, which `with` uses.
        var c = a with { LastName = "Byron" };
        Console.WriteLine(c);
        Console.WriteLine(a);    // unchanged -- non-destructive
    }
}

Every one of these can be replaced by writing it yourself; the compiler only supplies what you have not.

with Expressions

with copies the instance, then applies the listed initialisers to the copy:

public record Order2(string Customer, decimal Total, bool Paid)
{
    public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}

public static class WithExpressions
{
    public static void Run()
    {
        var order = new Order2("Ada", 99.50m, Paid: false);

        var paid = order with { Paid = true };
        var discounted = paid with { Total = paid.Total * 0.9m };

        Console.WriteLine(order.Paid);          // False -- the original is untouched
        Console.WriteLine(discounted.Paid);     // True  -- copied through
        Console.WriteLine(discounted.Total);    // 89.55
        Console.WriteLine(discounted.CreatedAt == order.CreatedAt);   // True -- copied
    }
}

The copy is shallow: a mutable reference-typed member is shared between the original and the copy. Keep record members immutable (and prefer IReadOnlyList<T> or an immutable collection over List<T>) or with will surprise you.

init-Only Properties

init is settable in an object initializer or a with expression, and read-only afterwards:

public record Config
{
    public required string Host { get; init; }
    public int Port { get; init; } = 443;

    // An init accessor may validate, exactly like a set accessor.
    public int Retries
    {
        get;
        init => field = value < 0
            ? throw new ArgumentOutOfRangeException(nameof(value))
            : value;
    } = 3;
}

public static class InitDemo
{
    public static void Run()
    {
        var config = new Config { Host = "example.com", Retries = 5 };
        // config.Host = "other";     // error CS8852: init-only property
        Console.WriteLine($"{config.Host}:{config.Port} x{config.Retries}");
    }
}

init is not exclusive to records — any class or struct property can use it.

Record Inheritance and EqualityContract

A record class may derive from another record (but not from a plain class, and a plain class may not derive from a record):

public abstract record Shape3(string Name);

public sealed record Circle3(double Radius) : Shape3("circle");

public sealed record Square3(double Side) : Shape3("square");

public static class RecordInheritance
{
    public static void Run()
    {
        Shape3 a = new Circle3(2);
        Shape3 b = new Circle3(2);
        Shape3 c = new Square3(2);

        Console.WriteLine(a == b);      // True
        Console.WriteLine(a.Equals(c)); // False -- different runtime types
        Console.WriteLine(a);           // Circle3 { Name = circle, Radius = 2 }

        var bigger = (Circle3)a with { Radius = 3 };
        Console.WriteLine(bigger);
    }
}

The type check is done by a synthesised protected virtual Type EqualityContract ⇒ typeof(…) property that each record overrides. Because Equals compares EqualityContract first, a Circle3 never equals a Square3 even if every field matches — which is the behaviour a hand-written Equals usually gets wrong.

with on a base-typed variable returns the runtime type, because it calls the virtual Clone. That is why the cast above is needed only to get at Radius, not to make the copy correct.

Customising ToString

Override PrintMembers to change what ToString prints while keeping the surrounding format, or override ToString outright:

public record Secretive(string Name, string ApiKey)
{
    protected virtual bool PrintMembers(StringBuilder builder)
    {
        builder.Append("Name = ").Append(Name).Append(", ApiKey = ****");
        return true;      // true means "I wrote something", so the braces are spaced
    }
}
// new Secretive("svc", "s3cr3t").ToString() => Secretive { Name = svc, ApiKey = **** }

On a sealed record or a record struct, PrintMembers is private/readonly rather than protected virtual.

Records, Classes, Structs and Tuples

Choose When

readonly record struct

Small (≲ 16 bytes), immutable, value-equal data used often — coordinates, money, identifiers.

record class

Data of any size with value equality — DTOs, domain events, query results, configuration.

class

The type has identity, mutable shared state, inheritance, or is a service rather than data.

struct

A value type needing custom (non-member-wise) equality or layout control.

tuple (double X, double Y)

A short-lived, local, unnamed grouping — a multi-value return, never a public API.

A useful rule: if you find yourself writing Equals, GetHashCode and ToString by hand for a data type, you wanted a record. If you find yourself giving a tuple element names in three different methods, promote it to a record.

// Was: a tuple repeated everywhere
static (double Latitude, double Longitude) GetLocationTuple() => (40.4, -3.7);

// Became: a record struct with a name, methods and equality
public readonly record struct Location(double Latitude, double Longitude)
{
    public static Location Origin => new(0, 0);
    public override string ToString() => $"({Latitude:F4}, {Longitude:F4})";
}

Records in Pattern Matching

Positional records work directly with positional patterns, which is what makes them so good as the data half of a data-driven algorithm:

public abstract record Json
{
    public sealed record Str(string Value) : Json;
    public sealed record Num(double Value) : Json;
    public sealed record Arr(IReadOnlyList<Json> Items) : Json;
}

public static class JsonRender
{
    public static string Render(Json node) => node switch
    {
        Json.Str("") => @"""""",
        Json.Str(var s) => $"\"{s}\"",
        Json.Num(var n) and { Value: > 1000 } => $"{n:E2}",
        Json.Num(var n) => n.ToString(CultureInfo.InvariantCulture),
        Json.Arr({ Count: 0 }) => "[]",
        Json.Arr(var items) => $"[{string.Join(",", items.Select(Render))}]",
        _ => throw new NotSupportedException(),
    };
}

See Also