Records and pattern matching in C# were designed to work as a pair. Records give you concise data types with value equality and nondestructive updates, and pattern matching lets you inspect those values by type, shape and content in a single expression. This guide is for developers who already write C# every day and want to use both features deliberately: you will learn exactly what the compiler generates for record class and record struct, where equality and with expressions surprise people, how every pattern type works, and how to model domain states so the compiler flags unhandled cases, including the closed hierarchies and union types that arrive with C# 15.

What Are Records and Pattern Matching in C#?#

A record is not a separate kind of type. The record keyword is a modifier on a class or struct that makes the compiler generate what a data-centric type needs: value equality, a readable ToString, with support and, with positional syntax, a constructor, properties and Deconstruct. Plain record means record class, a reference type, while record struct is a value type.

Pattern matching is the other half. A pattern describes a shape a value might have: a type, a constant, a range, property values, tuple positions or list elements. You apply patterns with is, in switch statements and in switch expressions, and the compiler checks them for redundancy and completeness.

Together they support data-oriented programming: model data as small immutable types, then write functions that pattern match over them. It suits messages, commands, events, API contracts and operation results. Both features have grown release by release:

C# versionDefault forRecordsPattern matching
C# 7.x.NET Framework, .NET Core 2.xNoneDeclaration, constant and var patterns in is and switch
C# 8.NET Core 3.xNoneswitch expressions, property, tuple and positional patterns
C# 9.NET 5record types and init accessorsRelational, logical (and, or, not), type and parenthesized patterns
C# 10.NET 6record struct, sealed ToString, with on any structExtended property patterns such as { A.B: 1 }
C# 11.NET 7required membersList and slice patterns, Span<char> against constant strings
C# 15.NET 11closed class hierarchiesUnion types with exhaustive matching

How Records Work Under the Hood#

Everything a record does comes from code the compiler writes for you. Take a one-line positional record:

C#
public record Person(string FirstName, string LastName);

From that single line the compiler generates:

  • A primary constructor and two public init-only properties, FirstName and LastName.
  • A Deconstruct(out string FirstName, out string LastName) method used by deconstruction and positional patterns.
  • An override of Equals(object?), a strongly typed Equals(Person?) that implements IEquatable<Person>, a matching GetHashCode, and the == and != operators.
  • A protected virtual EqualityContract property that returns the runtime type, so a Person never equals an instance of a derived record that happens to hold the same values.
  • A ToString override that calls a protected virtual PrintMembers(StringBuilder) method and prints Person { FirstName = Ada, LastName = Lovelace }.
  • A protected copy constructor and a hidden virtual clone method that power with expressions.

Two details of the generated equality matter in real code. It compares every instance field declared in the record, not just public properties, using EqualityComparer<T>.Default, so even a private cache field takes part in equality. And the hash code combines the same fields, so a record used as a dictionary key must not change after insertion.

The copy constructor copies fields directly without running initializers, and a with expression then calls the init accessors of the properties you list. Keep both facts in mind when you add validation.

Getting Started with Records and Switch Expressions#

This minimal example combines a positional record, with, value equality and a switch expression:

C#
var price = new Money(100m, "USD");
var discounted = price with { Amount = 80m };

Console.WriteLine(price);                           // Money { Amount = 100, Currency = USD }
Console.WriteLine(price == new Money(100m, "USD")); // True: compared by value
Console.WriteLine(Describe(discounted));            // Regular USD amount

static string Describe(Money money) => money switch
{
    { Amount: < 0m } => "Refund",
    { Amount: 0m } => "Free",
    { Amount: >= 1_000m, Currency: var currency } => $"Large {currency} amount",
    { Currency: var currency } => $"Regular {currency} amount",
};

public sealed record Money(decimal Amount, string Currency);

The with expression leaves price untouched and returns a new instance. Because the last arm matches any non-null Money, the compiler accepts the switch as exhaustive without a discard arm.

Record Class vs Record Struct in C#

The record modifier never changes the underlying semantics of the type: assigning a record class copies a reference, and assigning a record struct copies the data. The main surprise is mutability: positional properties are init-only in a record class and a readonly record struct, but read-write in a plain record struct.

Aspectrecord / record classrecord structreadonly record struct
Kind of typeReference typeValue typeValue type
Positional propertiesinit-onlyRead-writeinit-only
AllocationOne heap object per instanceInline in locals, arrays and fieldsInline in locals, arrays and fields
InheritanceFrom other records onlyNot supportedNot supported
How with copiesVirtual clone plus copy constructorPlain struct copy, then settersPlain struct copy, then init accessors
Can be nullYesOnly as Nullable<T>Only as Nullable<T>
Typical useDTOs, messages, events, hierarchiesSmall values you mutate locallyMoney, IDs, coordinates, keys

Record structs also fix a weakness of plain structs, whose default ValueType.Equals can fall back to reflection: a record struct gets generated, strongly typed equality and the == operator. Strongly typed identifiers are a textbook use:

C#
var id = OrderId.New();
var copy = id;                     // copies 16 bytes, no allocation
Console.WriteLine(id == copy);     // True

var position = new Coordinate(59.91, 10.75);
position.Latitude = 60.0;          // allowed: plain record struct properties are read-write

OrderId unset = default;           // wraps Guid.Empty: every struct has a default value
Console.WriteLine(unset.Value == Guid.Empty); // True

public readonly record struct OrderId(Guid Value)
{
    // Guid.CreateVersion7 (.NET 9 and later) creates time-ordered identifiers.
    public static OrderId New() => new(Guid.CreateVersion7());

    public override string ToString() => Value.ToString("N");
}

public record struct Coordinate(double Latitude, double Longitude);

The default line shows the trade-off: any struct can be created without running a constructor, so a readonly record struct cannot guarantee an invariant such as "never empty". Validate where such values enter your system, and keep record structs small, because every assignment copies the whole value.

Positional Records, Required Members and Validation#

Positional syntax is ideal when a few values fully describe a type. For larger types, a nominal record with required properties (C# 11) reads better at the call site and survives parameter reordering.

Validation is where records need care. A with expression never calls your constructor: it clones the instance and runs the init accessors of the properties you set. A property initializer, meanwhile, writes the backing field directly and skips the accessor. To enforce an invariant on both paths, redeclare the positional property and validate in both places. The C# 14 field keyword keeps this compact, as the C# 14 features guide explains:

C#
using System.Text.Json.Serialization;

public sealed record OrderLine(
    [property: JsonPropertyName("sku")] string Sku,
    int Quantity,
    decimal UnitPrice)
{
    // The initializer guards construction; the init accessor guards `with` and initializers.
    public int Quantity
    {
        get;
        init => field = EnsurePositive(value);
    } = EnsurePositive(Quantity);

    // Computed on access, so it stays correct after a `with` expression.
    public decimal LineTotal => Quantity * UnitPrice;

    private static int EnsurePositive(int value)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
        return value;
    }
}

public sealed record CustomerProfile
{
    public required string DisplayName { get; init; }
    public required string Email { get; init; }
    public string? Phone { get; init; }
}

The property: target applies an attribute to the generated property rather than to the constructor parameter, which is how you control JSON names or validation attributes on positional records. System.Text.Json binds positional records through their constructor, as the System.Text.Json deep dive shows.

Value Equality in C# Records: What Gets Compared#

Record equality is only as deep as the equality of each field type. Strings, numbers, enums, other records and readonly record struct values compare by value. Arrays, List<T>, dictionaries and most other collections compare by reference, which silently breaks the value semantics you expected:

C#
var a = new Tagged("invoice", ["urgent", "eu"]);
var b = new Tagged("invoice", ["urgent", "eu"]);
Console.WriteLine(a == b); // False: string[] compares by reference

var c = new TaggedFixed("invoice", ["urgent", "eu"]);
var d = new TaggedFixed("invoice", ["urgent", "eu"]);
Console.WriteLine(c == d); // True: the custom Equals compares the sequence

public sealed record Tagged(string Name, string[] Tags);

public sealed record TaggedFixed(string Name, IReadOnlyList<string> Tags)
{
    public bool Equals(TaggedFixed? other) =>
        other is not null && Name == other.Name && Tags.SequenceEqual(other.Tags);

    public override int GetHashCode()
    {
        var hash = new HashCode();
        hash.Add(Name);
        foreach (var tag in Tags)
        {
            hash.Add(tag);
        }

        return hash.ToHashCode();
    }
}

You may declare Equals(R? other) and GetHashCode yourself, and the compiler warns if you provide only one. You cannot declare Equals(object?), == or !=, because the synthesized versions route through your strongly typed Equals. In a non-sealed record, your Equals(R?) must be virtual and should also compare EqualityContract, one more reason to seal leaf records.

with Expressions: Nondestructive Mutation Is Shallow#

A with expression copies the instance and then sets the listed members. The copy is shallow: reference-type members still point to the same objects, and values computed during construction are copied rather than recomputed.

C#
var draft = new Invoice("INV-1001", ["Consulting"]);
var revised = draft with { Number = "INV-1001-R1" };

revised.Lines.Add("Travel");
Console.WriteLine(draft.Lines.Count);    // 2: both invoices share one List<string>

var isolated = draft with { Lines = [.. draft.Lines] }; // copy the collection explicitly
isolated.Lines.Add("Hotel");
Console.WriteLine(draft.Lines.Count);    // still 2

var box = new Rectangle(2, 3) with { Width = 10 };
Console.WriteLine(box.Area);             // 6, not 30: Area was computed before `with` ran
Console.WriteLine(box.Perimeter);        // 26: computed on access

public sealed record Invoice(string Number, List<string> Lines);

public sealed record Rectangle(double Width, double Height)
{
    public double Area { get; } = Width * Height;
    public double Perimeter => 2 * (Width + Height);
}

Use immutable collections or copy collections explicitly inside with, and compute derived values on access. Since C# 10, with also works on any struct and on anonymous types, where the clone step is an ordinary value copy.

Inheritance with Records#

Record classes support inheritance with a few rules: a record can derive only from another record, a class cannot derive from a record, and record structs cannot participate in inheritance at all. Equality includes the runtime type through EqualityContract, and with preserves the runtime type because the clone method is virtual:

C#
Person ada = new Person("Ada", "Lovelace");
Person employee = new Employee("Ada", "Lovelace", "E-042");

Console.WriteLine(ada == employee);   // False: the runtime types differ

Person renamed = employee with { LastName = "King" };
Console.WriteLine(renamed.GetType().Name); // Employee
Console.WriteLine(renamed);
// Employee { FirstName = Ada, LastName = King, EmployeeId = E-042 }

public record Person(string FirstName, string LastName);

public record Employee(string FirstName, string LastName, string EmployeeId)
    : Person(FirstName, LastName);

A with expression can set only members of the compile-time type, even though all members of the runtime type are copied, and Deconstruct also follows the compile-time type. Since C# 10, a base record can declare public sealed override string ToString() to fix the format for the whole hierarchy.

Keep record hierarchies shallow: an abstract base that names the concept and sealed leaves that carry the data. That shape gives cheap type checks, predictable equality and a natural fit for pattern matching.

Pattern Matching in C#: Every Pattern Type#

Patterns compose, so you can nest one almost anywhere another is allowed:

PatternExampleIntroduced
Declarationshape is Circle cC# 7
Typeshape is Circle, or Circle => ... in a switch armC# 9
Constantcode is 404, value is nullC# 7
Relationaltemperature is > 30.0C# 9
Logicalc is >= 'a' and <= 'z' or '_'C# 9
Propertyorder is { Total: > 100m }C# 8
Extended propertyorder is { Customer.Address.Country: "NO" }C# 10
Positionalpoint is (0, 0)C# 8
varis var x, (var x, var y)C# 7
Discard_ => ... in a switch expressionC# 8
List and sliceargs is ["run", .. var rest]C# 11

Constant, relational and logical patterns handle scalar values. Remember the precedence: not binds tightest, then and, then or, so use parentheses whenever you mix them:

C#
static string Classify(int statusCode) => statusCode switch
{
    >= 200 and < 300 => "Success",
    301 or 302 or 307 or 308 => "Redirect",
    429 => "Throttled",               // must precede the 4xx range, or it is unreachable
    >= 400 and < 500 => "Client error",
    >= 500 and < 600 => "Server error",
    _ => "Unexpected",
};

static bool IsIdentifierStart(char c) =>
    c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '_';

// Since C# 11, spans of char match constant strings without allocating.
static bool IsYes(ReadOnlySpan<char> answer) => answer is "y" or "yes" or "Y" or "YES";

x is null and x is not null never call a user-defined == operator, which makes them safer than x == null. The empty property pattern x is { } value checks for null and introduces a non-null variable at once.

Structural patterns are where records shine. Property patterns read members, extended property patterns reach through nested members, positional patterns call Deconstruct, and list patterns match sequences. A list pattern works on any type that is countable (Length or Count) and indexable (an indexer taking Index or int): arrays, List<T>, IReadOnlyList<T>, spans and strings. A slice that captures a value, such as .. var rest, also requires range support or a Slice method.

C#
public sealed record Address(string City, string Country);
public sealed record Customer(string Name, Address Address, bool IsVip);
public sealed record Order(Customer Customer, decimal Total, IReadOnlyList<string> Skus);

public abstract record Command;
public sealed record ShowHelp : Command;
public sealed record Run(string Project, bool Verbose) : Command;
public sealed record Test(string[] Filters) : Command;

public static class Rules
{
    public static decimal ShippingCost(Order order) => order switch
    {
        { Skus: [] } => throw new InvalidOperationException("Order has no items."),
        { Customer.IsVip: true } => 0m,
        { Customer.Address.Country: "US", Total: >= 50m } => 0m,
        { Customer.Address: (_, "US") } => 4.99m,        // positional: Deconstruct(City, Country)
        { Skus: [.., "OVERSIZE-CRATE"] } => 49.00m,      // last element matches
        { Skus.Count: > 10 } => 19.99m,
        _ => 12.50m,
    };

    public static Command Parse(string[] args) => args switch
    {
        [] or ["help"] or ["--help"] => new ShowHelp(),
        ["run", var project] => new Run(project, Verbose: false),
        ["run", var project, "--verbose"] => new Run(project, Verbose: true),
        ["test", .. var filters] => new Test(filters),
        [var unknown, ..] => throw new ArgumentException($"Unknown command '{unknown}'."),
    };
}

Note that Parse has no discard arm. The compiler assumes lengths are never negative, so [] plus [var unknown, ..] already covers every array.

Switch Expressions and Exhaustiveness#

A switch expression evaluates its arms in source order and returns the first arm whose pattern matches and whose optional when guard is true. Two kinds of diagnostics make it safer than an if chain:

  • Unreachable arms are errors. If earlier arms already match every value a later arm could match, you get CS8510. Moving the 429 arm below the 4xx range above triggers it.
  • Missing cases are warnings. CS8509 names an example value that no arm handles. CS8524 covers enums where every named member is handled but unnamed values such as (Status)42 are not. CS8846 appears when only a when guard might handle a value.

If no arm matches at runtime, .NET throws SwitchExpressionException. A trailing discard arm silences the warning forever, including when someone adds a case next year. For enums you own, a useful policy is to omit the discard, suppress CS8524 with <NoWarn>$(NoWarn);CS8524</NoWarn>, and add CS8509 to <WarningsAsErrors> in the project file. A new enum member then breaks the build at every switch that forgot it, while out-of-range values still throw at runtime.

C#
public enum ShipmentStatus { Pending, InTransit, Delivered, Returned }

public static class ShipmentText
{
    // No discard arm: a new ShipmentStatus member produces CS8509 here.
    public static string Describe(ShipmentStatus status) => status switch
    {
        ShipmentStatus.Pending => "Waiting for pickup",
        ShipmentStatus.InTransit => "On the way",
        ShipmentStatus.Delivered => "Delivered",
        ShipmentStatus.Returned => "Returned to sender",
    };

    // Tuple patterns match several inputs at once; guards refine a single arm.
    public static string Priority(ShipmentStatus status, TimeSpan waited) => (status, waited) switch
    {
        (ShipmentStatus.Pending, var age) when age > TimeSpan.FromDays(2) => "Escalate",
        (ShipmentStatus.Pending or ShipmentStatus.InTransit, _) => "Normal",
        _ => "None",
    };
}

When an arm is truly impossible, throw System.Diagnostics.UnreachableException (.NET 7 and later) from a discard arm rather than returning a fallback value that hides bugs. Note that switch statements, unlike switch expressions, are never required to be exhaustive.

Modeling Domain States with Records and Patterns#

The most valuable use of records and patterns is making illegal states unrepresentable. Instead of one mutable Order class with a Status enum and nullable fields that are valid only in some states, give each state a record that carries exactly the data valid in that state: a paid order always has a payment ID, and a draft never does. Transitions become pure functions over the current state and the incoming command, which pairs well with the tactical patterns in the Domain-Driven Design guide.

C#
namespace Shop.Ordering;

public sealed record OrderLine(string Sku, int Quantity, decimal UnitPrice);

public abstract record OrderState
{
    public sealed record Draft(IReadOnlyList<OrderLine> Lines) : OrderState;
    public sealed record Placed(IReadOnlyList<OrderLine> Lines, DateTimeOffset At) : OrderState;
    public sealed record Paid(Placed Order, string PaymentId) : OrderState;
    public sealed record Shipped(Paid Order, string TrackingNumber) : OrderState;
    public sealed record Cancelled(string Reason, DateTimeOffset CancelledAt) : OrderState;
}

public abstract record OrderCommand
{
    public sealed record Place(DateTimeOffset At) : OrderCommand;
    public sealed record Pay(string PaymentId) : OrderCommand;
    public sealed record Ship(string TrackingNumber) : OrderCommand;
    public sealed record Cancel(string Reason, DateTimeOffset At) : OrderCommand;
}

public static class OrderWorkflow
{
    public static OrderState Apply(OrderState state, OrderCommand command) =>
        (state, command) switch
        {
            (OrderState.Draft { Lines: [] }, OrderCommand.Place) =>
                throw new InvalidOperationException("Cannot place an empty order."),
            (OrderState.Draft draft, OrderCommand.Place place) =>
                new OrderState.Placed(draft.Lines, place.At),
            (OrderState.Placed placed, OrderCommand.Pay pay) =>
                new OrderState.Paid(placed, pay.PaymentId),
            (OrderState.Paid paid, OrderCommand.Ship ship) =>
                new OrderState.Shipped(paid, ship.TrackingNumber),
            (OrderState.Draft or OrderState.Placed, OrderCommand.Cancel cancel) =>
                new OrderState.Cancelled(cancel.Reason, cancel.At),
            _ => throw new InvalidOperationException(
                $"{command.GetType().Name} is invalid for a {state.GetType().Name} order."),
        };
}

Every legal transition is visible in one expression, invalid ones fail loudly, and the function is trivial to unit test. The limitation before C# 15 is that OrderState is open: any record in any assembly can derive from it, so the compiler cannot prove a switch over it complete, and you need a discard arm.

Closed Hierarchies and Union Types in C# 15#

C# 15 closes that gap. .NET 11 Release Candidate 1, released on September 8, 2026 with a go-live license, makes C# 15 the default language version for net11.0 projects and stabilizes both features ahead of general availability in November 2026:

  • The closed modifier restricts direct subtypes of a class to its declaring assembly. A closed class is implicitly abstract, and a switch that handles every direct subtype is exhaustive with no discard arm. The restriction is not transitive, so mark intermediate types closed too, and a nullable input still needs a null arm.
  • The union keyword composes existing types, which need not share a base class, into a closed set of case types. Each case type converts implicitly to the union, and patterns match the union's contents. The generated struct stores its value as object?, so value-type cases are boxed; hot paths can use a hand-written union that follows the non-boxing access pattern.
C#
// C# 15 (.NET 11): only this assembly can add direct subtypes of PaymentState.
public closed record class PaymentState;
public sealed record Authorized(string AuthorizationCode) : PaymentState;
public sealed record Captured(string AuthorizationCode, decimal Amount) : PaymentState;
public sealed record Voided(string Reason) : PaymentState;

public sealed record Customer(string Name);
public sealed record NotFound;
public sealed record ValidationError(string Message);

// A union of unrelated types: no shared base class required.
public union LookupResult(Customer, NotFound, ValidationError);

public static class Describer
{
    public static string Describe(PaymentState state) => state switch
    {
        Authorized a => $"Authorized ({a.AuthorizationCode})",
        Captured c => $"Captured {c.Amount:C}",
        Voided v => $"Voided: {v.Reason}",
    }; // exhaustive: no discard arm and no CS8509

    public static string Describe(LookupResult result) => result switch
    {
        Customer c => $"Found {c.Name}",
        NotFound => "No such customer",
        ValidationError e => $"Invalid input: {e.Message}",
    };
}

Choose a closed hierarchy when the cases belong to one concept and share data or behavior, such as order states. Choose a union to combine types that exist independently, such as a customer, a not-found marker and a validation error. On .NET 8, 9 and 10, keep using abstract records with a discard arm.

Best Practices#

  • Seal leaf records. sealed record makes type checks cheaper, simplifies equality and prevents accidental inheritance.
  • Prefer readonly record struct for small values. Money, identifiers and coordinates avoid heap allocations and get fast generated equality.
  • Make immutability deep. Use ImmutableArray<T> or read-only views over collections nobody mutates.
  • Validate in init accessors as well as constructors. A with expression skips the constructor.
  • Order switch arms from specific to general. Let CS8510 catch shadowed arms, and parenthesize logical patterns that mix and, or and not.
  • Hide sensitive data from ToString. Override PrintMembers or seal ToString on records that hold secrets or personal data, because records get logged.

Common Pitfalls#

  • Collections break value equality. Arrays and List<T> compare by reference inside records, so write custom Equals and GetHashCode or wrap the collection in a value object.
  • with is shallow. Records produced by with share every reference-type member.
  • Stale computed properties. A property initialized from other properties is copied as-is by with.
  • Negated patterns that read wrong. status is not Active or Suspended means (not Active) or Suspended, which is true for everything except Active. Write status is not (Active or Suspended).
  • Records as EF Core entities. EF Core tracks entities by reference identity, so use classes for entities and records for DTOs and value objects, as the EF Core guide explains.
  • Mutable record structs as dictionary keys. Changing a property after insertion changes the hash code, and lookups silently fail.

Records vs Classes vs Structs: When to Use Each#

NeedBest fitWhy
Immutable DTO, API contract, message or eventsealed recordValue equality, with, concise declaration
Small immutable value such as money or an IDreadonly record structNo allocation, generated equality
Entity with identity and lifecycle, especially with EF CoreclassReference identity and change tracking
Service with dependencies and behaviorclass, often with a primary constructorValue semantics add nothing
Closed set of alternatives on .NET 8 to 10Abstract record with sealed nested recordsPattern matching with a discard arm
Closed set of alternatives on .NET 11closed record hierarchy or unionCompiler-verified exhaustiveness
Hot-path mutable datastructFull control over layout and mutation

Frequently Asked Questions#

Are C# records immutable?#

Record classes and readonly record struct types generate init-only positional properties, so they are immutable after construction by default. A plain record struct generates read-write properties, and any record can declare mutable members. Even then, immutability is shallow: referenced objects such as lists can still change.

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

A record class is a reference type allocated on the heap that supports inheritance, while a record struct is a value type copied on assignment with no inheritance. Both get generated value equality, ToString, Deconstruct and with support. Use record structs for small values and record classes for larger data or hierarchies.

Does a with expression create a deep copy?#

No. A with expression performs a shallow copy: value-type members are copied, but reference-type members such as lists still point to the same objects. Copy collections explicitly, for example with record with { Items = [.. record.Items] }, or use immutable collections.

Can I use records as Entity Framework Core entities?#

Microsoft's guidance is to avoid records for EF Core entity types, because EF Core relies on reference equality to track entities. Records work well for DTOs, projections, value objects and messages around your EF Core model.

How do I make a switch expression exhaustive over a record hierarchy?#

Before C# 15, the compiler cannot know every subtype of an open hierarchy, so you need a discard arm, ideally one that throws. With C# 15 on .NET 11, mark the base record closed so that handling every direct subtype is exhaustive, or use a union type when the cases do not share a base class.

Summary#

  • The record modifier adds value equality, ToString, Deconstruct and with support on top of ordinary class or struct semantics.
  • Use record class for reference semantics and hierarchies, and readonly record struct for small immutable values.
  • Equality compares every field with its default comparer, so collections need care, and with copies are shallow.
  • Patterns compose, and switch expressions report unreachable arms and missing cases, so avoid discard arms that hide gaps.
  • Model domain states as records and transitions as pattern-matching functions, and adopt closed hierarchies and unions with C# 15 on .NET 11.

Further Reading#