A record is a class or struct for data whose equality is based on its members; the compiler supplies equality, display, and copying support.
A record isn’t deeply immutable, and with makes a shallow copy. Mutable members can change both copies or make a record unsafe as a hash key.
Choose the class or struct form deliberately, keep equality members stable, and test nested references, default struct values, and copied invariants.
What it is and why it exists
A record type is a class or struct whose compiler-generated contract treats data as the type’s defining feature. Two separately allocated record objects can compare equal when their corresponding members compare equal. A plain class instead uses reference identity unless you implement another equality contract.
The record modifier asks the compiler to synthesize value equality, matching hash-code behavior, a readable ToString(), and support for with expressions. A positional declaration also supplies a primary constructor, public properties, and Deconstruct(). These members remove repetitive code, but they do not decide which data belongs in the type.
Records fit data transfer objects, messages, coordinates, money-like values, and snapshots when content determines identity. They are a poor default for an entity whose identity survives changing attributes. If two customers remain the same customer after a name change, an ID-based class contract may say that more clearly than equality over every record member.
record and record class declare a reference type. record struct and readonly record struct declare a value type . The forms share compiler-generated record features, but assignment, nullability, inheritance, and default-value behavior still follow their underlying class or struct semantics.
A record does not promise immutability. Positional record-class properties have init accessors, yet the record can declare settable properties and can contain mutable objects. Positional properties of a non-readonly record struct are read-write by default.
How it works
Positional declarations and generated members
public record OrderLine(string Sku, int Quantity); declares a record class. Its two positional parameters become public init-only properties, constructor parameters, and outputs of a synthesized Deconstruct() method. They also participate in the compiler-generated equality and string representation.
A nominal record uses an ordinary body instead of positional parameters. It still gets the record equality, hash, display, and copy members, but it does not get a primary constructor or Deconstruct() merely because it is a record. Use this form when property names, defaults, accessors, or construction rules need more space.
An init accessor permits assignment during object construction and in a with initializer, then rejects ordinary assignment. This is shallow immutability. If an init-only property refers to a List<T>, callers can still mutate that list after construction.
The generated surface depends on the declaration:
| Declaration | Underlying kind | Positional properties | Inheritance | default value |
|---|---|---|---|---|
record / record class | Reference type | get; init; | Other record classes | null |
record struct | Value type | get; set; | No struct inheritance | Zero-initialized value |
readonly record struct | Value type | get; init; | No struct inheritance | Zero-initialized value |
Equality and hash codes
Compiler-generated record equality is structural equality over the record’s instance state. Each member uses its own equality contract. Strings compare by their contents, but arrays and common mutable list classes compare by reference unless another comparer or wrapper says otherwise.
Equal records produce equal hash codes during the same execution, which lets records serve as dictionary keys and set elements. The reverse is not guaranteed: unequal values can collide. More importantly, every value that participates in equality and hashing must remain stable while the record is stored in a hash-based collection.
Changing a settable property after insertion can change the generated hash code. The dictionary then looks in a different bucket and may fail to find the very object used as a key. init reduces this risk for top-level properties but does not stop a nested object from changing its own equality behavior.
Record-class inheritance adds a runtime-type check to equality. A base record instance and a derived record instance are not equal merely because their shared members match. This preserves symmetry: both baseValue.Equals(derivedValue) and the reverse must reach the same answer.
Copying with with
A with expression creates a copy and then assigns the members named in its initializer. For a record class, compiler-generated machinery creates a new object through copy semantics. For a record struct, the value is copied and the selected fields or properties are changed on that copy.
The default result is a shallow copy . Reference-valued members still point to the same nested instances unless the initializer replaces them. original with { Name = "new" } does not clone an array, list, dictionary, or object held by another member.
For a record-class hierarchy, the result preserves the operand’s runtime type. A variable statically typed as a base record can refer to a derived record; applying with produces another derived object. The initializer can name only members available through the receiver’s compile-time type.
Copying also matters for derived or cached values. A property initializer calculated from positional parameters runs for the original construction and its stored result is copied. If with later changes an input property, the stored calculation can become stale; compute dependent values on access or re-establish the invariant explicitly.
Choosing equality before syntax
The deciding question is which values make two instances interchangeable. If the answer is all stable member values, a record’s generated contract is useful. If equality depends on one database identity, normalization policy, sequence contents, or a domain-specific tolerance, do not accept generated equality without reviewing it.
Copying semantics come next. A record class copies a reference on ordinary assignment and creates a new outer object with with. A record struct copies its fields on ordinary assignment as well, so large or mutable structs can surprise callers even before a with expression appears.
Construction rules are separate. required can require a caller to initialize a member, but it does not validate ranges or cross-member relationships. Constructors, factories, and validating accessors still need tests for invalid values, and default(SomeRecordStruct) remains possible regardless of constructor checks.
Examples
These examples move from the generated positional surface to shallow copying, nested-member equality, and a record hierarchy. The local environment has no .NET SDK or C# compiler, so the blocks are marked unexecuted and no output is invented.
Seeing the positional surface
Two separately created order lines compare equal. Deconstruction follows positional order, while with creates a changed outer object and leaves the original untouched.
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
OrderLine first = new("KB-42", 2);
OrderLine duplicate = new("KB-42", 2);
OrderLine revised = first with { Quantity = 3 };
var (sku, quantity) = first;
Console.WriteLine(first == duplicate);
Console.WriteLine(ReferenceEquals(first, duplicate));
Console.WriteLine($"{sku}: {quantity}");
Console.WriteLine(first);
Console.WriteLine(revised);
public sealed record OrderLine(string Sku, int Quantity);# not executed here: the .NET SDK and C# compilers are unavailableThe generated constructor accepts the boundary values in the challenge because the declaration contains no validation. Equality is ordinal member equality here: the compiler does not trim the SKU, fold case, or infer that quantity must be positive.
sealed prevents a derived record from extending the equality domain. It is a useful choice for a small value with a closed meaning, though it does not make referenced members immutable.
Exposing a shallow copy
The with expression changes Version, but it copies the Tags reference. Mutating the list through either record is visible through both.
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
using System.Collections.Generic;
ReleaseNotes original = new("1.0", ["preview"]);
ReleaseNotes published = original with { Version = "1.1" };
published.Tags.Add("stable");
Console.WriteLine(ReferenceEquals(original, published));
Console.WriteLine(ReferenceEquals(original.Tags, published.Tags));
Console.WriteLine(string.Join(", ", original.Tags));
Console.WriteLine(string.Join(", ", published.Tags));
public sealed record ReleaseNotes(
string Version,
List<string> Tags);# not executed here: the .NET SDK and C# compilers are unavailableAn IReadOnlyList<string> property would limit mutation through that interface, but it would not copy the supplied object or guarantee an immutable implementation. Define who owns the collection and take a defensive or immutable snapshot when the record contract requires one.
Custom copy behavior can clone nested state for a record class, but a surprise deep copy is not automatically better. Specify which graph edges are copied; otherwise identity-sensitive objects, resources, and cycles make “clone everything” ambiguous.
Testing nested-member equality
Arrays use reference equality for their own Equals implementation. The first two keys share one array and compare equal; the third has the same elements in a different array and does not satisfy generated record equality.
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
using System.Collections.Generic;
string[] sharedScopes = ["read", "write"];
CacheKey first = new("tenant-a", sharedScopes);
CacheKey sameReference = new("tenant-a", sharedScopes);
CacheKey sameContents = new("tenant-a", ["read", "write"]);
HashSet<CacheKey> keys = [first, sameReference, sameContents];
Console.WriteLine(first == sameReference);
Console.WriteLine(first == sameContents);
Console.WriteLine(keys.Count);
public sealed record CacheKey(
string TenantId,
string[] Scopes);# not executed here: the .NET SDK and C# compilers are unavailableReplacing the array with IReadOnlyList<string> does not change equality by itself. If sequence contents define the value, wrap them in a type with sequence equality or implement a complete equality-and-hash contract. A custom Equals without a matching GetHashCode is broken for hash collections.
For a cache key, include every isolation field such as tenant, locale, permissions, and normalization version. Record syntax cannot tell whether an omitted field is harmless or a cross-tenant data leak.
Combining inheritance and patterns
The hierarchy gives each message a concise data shape. Pattern matching can then classify the derived runtime type and validate its values at the consuming boundary.
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
PaymentMessage[] messages =
[
new Authorized("P-17", 42m),
new Declined("P-18", "insufficient-funds")
];
foreach (PaymentMessage message in messages)
Console.WriteLine(Describe(message));
static string Describe(PaymentMessage message) => message switch
{
Authorized { Amount: > 0m } paid => $"paid {paid.Id}: {paid.Amount}",
Authorized => "invalid authorization",
Declined { Reason.Length: > 0 } failed => $"declined {failed.Id}: {failed.Reason}",
_ => "unknown payment message"
};
public abstract record PaymentMessage(string Id);
public sealed record Authorized(string Id, decimal Amount) : PaymentMessage(Id);
public sealed record Declined(string Id, string Reason) : PaymentMessage(Id);# not executed here: the .NET SDK and C# compilers are unavailableThe base type makes the accepted family visible, but C# record hierarchies are not sealed unions. Another assembly can derive from an accessible unsealed base, so the final arm still needs a deliberate unknown-type policy.
Pattern matching consumes the record’s shape; it does not validate construction. If a negative amount must never exist anywhere, establish that invariant before the message enters the hierarchy rather than relying on every consumer to repeat the same arm.
Pitfalls
Calling every record immutable
Fix: review every field and property, including nested objects. Use readonly record struct when value-type mutation is unwanted, and snapshot caller-owned collections when content must remain fixed.
Assuming with is a deep copy
Fix: test ReferenceEquals for nested members whose ownership matters. Replace a member in the initializer, use an immutable representation, or define documented custom record-class copy behavior when a deeper copy is part of the contract.
Trusting collection contents in generated equality
Fix: decide whether collection identity, order-sensitive contents, or set-like contents define equality. Encode that choice in a wrapper or a custom equality comparer and verify that equal values always produce equal hash codes.
Mutating a hash key
Fix: make hash-key state stable for the key’s whole residence time. Prefer a small immutable key record assembled from normalized scalar values, and test lookup after every operation that might mutate nested state.
Forgetting the zeroed record struct
Fix: either make the zero value meaningful, validate before use, or choose a record class or explicit optional wrapper when “no value” must be distinct. Constructor validation alone cannot prohibit a struct’s default value.
Letting copied properties go stale
Fix: express dependent values as computed getters when calculation is cheap, or route updates through an operation that rebuilds and validates all dependent state. Add a test that changes each source member through with and reads every derived member.
Equality is only as stable as its members
Generated comparison is composition
The compiler does not recursively inspect arbitrary objects to discover their contents. It composes the equality operations of the record’s members. This is predictable once each member contract is known, but the word “value” can hide that an array’s value contract is still reference-based.
Nullable members compare through their normal equality semantics. Floating-point members keep floating-point rules, including their treatment of NaN; strings keep their specified string equality. Domain normalization such as case folding, Unicode normalization, time-zone conversion, or monetary rounding is not inserted by the record feature.
If generated equality is not the domain contract, a purpose-built normalized field can be safer than a large custom equality implementation. Construct the field once from validated input and keep it stable. Custom equality must remain reflexive, symmetric, and transitive, and its hash code must use the same equality inputs.
Keys need a residence-time invariant
Hash collections assume a key’s equality and hash code stay stable from insertion until removal. This is a lifetime rule, not merely an “immutable type” label. A private mutation method can break it just as thoroughly as a public setter if it runs while the object is a key.
Nested mutable objects are subtle because their effect depends on their own contracts. Mutating a List<T> does not normally change the list object’s identity hash, but replacing the list reference changes the record hash. A custom sequence-equality wrapper may hash its elements, in which case changing an element can immediately break lookup.
Keep cache keys smaller than cached values. A key record should usually contain already-normalized identifiers and scalar options, not a complete request object with payloads, service references, or mutable collections. This also makes logs and tests easier to interpret without claiming a performance number that has not been measured.
Copying, validation, and derived state
Copy operations do not mean reconstruction
A record-class with operation uses its copy behavior, then applies member initializers. It does not call a public constructor as though the changed values had arrived together. Validation that exists only in that public constructor may therefore fail to govern the copied combination.
An init accessor for a member is invoked when that member is assigned in the with initializer, so member-local validation can still run. Cross-member rules remain harder: changing Start without changing End can violate an interval even if each timestamp is valid on its own. A named transition or factory can express the atomic rule better.
Record-class copy behavior can be customized with a copy constructor, while structure copy semantics cannot be customized in the same way. Use custom copying only when callers can describe exactly what is shared and what is duplicated. Copying open-ended object graphs is a separate serialization or cloning design problem.
Compute from current state
An expression-bodied getter such as Area => Width * Height uses current property values every time it is read. A get-only auto-property initialized with = Width * Height stores one result instead. After a shallow copy changes Width, that stored value can still describe the original.
Recomputation is not always free, but caching requires an invalidation contract. If the type is genuinely immutable and cannot be copied with changed inputs, storing a derived value may be safe. If with is part of the public API, test every permitted initializer against the cache or keep the derived result out of stored record state.
Serialization and deserialization add another construction path. Depending on the serializer and configuration, constructors, setters, required-member checks, and private state can behave differently. Test the actual serializer boundary rather than treating a valid new expression as proof that all materialized records satisfy the same invariants.
Inheritance and record shape
Record classes form their own hierarchy
A record class can derive from another record class, but a record cannot derive directly from an ordinary class, and an ordinary class cannot derive from a record. Record structs do not support user-defined class inheritance. Interfaces remain available to all these forms.
The generated equality contract includes type identity so a base value and a derived value do not become equal through only their common fields. Without that rule, adding derived state could make equality asymmetric or let a set treat objects with different contracts as interchangeable.
with uses virtual record-class copy behavior to preserve the runtime type. This is helpful for polymorphic snapshots, but it can surprise code looking only at the base variable. Inspect the runtime type and the derived members when a base-typed value crosses a copy boundary.
Positional shape is an API
Positional records expose constructor and deconstruction order. Swapping two parameters of the same type may still compile at call sites and silently change meaning. Named arguments help readers, but changing parameter names can itself affect source compatibility for those callers.
Property patterns are often clearer than positional patterns when a record has several same-typed members. { Start: 0, End: var end } carries its labels, while (0, var end) requires the reader to remember Deconstruct() order. Keep positional records short and reserve nominal syntax for evolving or validation-heavy contracts.
Adding a new positional parameter changes construction and deconstruction shape as well as equality. That is a larger compatibility change than adding a nonpositional convenience property. Review serialization schemas, pattern matches, generated clients, and persisted hashes before evolving a public record.
Choosing among class, record class, and record struct
A plain class is usually clearer when object identity, mutable lifecycle, encapsulated behavior, or framework tracking defines the model. It can still implement deliberate value equality, but doing so should be an explicit domain choice. The absence of record syntax does not prevent immutability.
A record class fits stable data with value equality and reference-type assignment. It can represent a large value without copying all fields on every ordinary assignment, and it supports record inheritance. with still allocates a new outer object, so update-heavy hot paths require measurement with the real workload.
A record struct fits a small, self-contained value whose field-by-field copying and zero value are acceptable. readonly record struct blocks ordinary mutation of its generated positional properties, but nested references can still point to mutable objects. Do not repeat the draft-era claim that structs necessarily live on the stack; storage depends on context and runtime behavior.
Choose from semantics first, then measure. Allocation count, equality cost, copying cost, boxing, and cache behavior depend on size and use. Rules such as “always use a struct below N bytes” need target-specific measurements and do not belong in a general reference without them.
Diagnosing unexpected record behavior
Start with the declared and runtime types of both operands. They tell you whether assignment copied a reference or fields, whether inheritance affects equality, and which members a with initializer can name. Then inspect member contracts instead of assuming “value equality” means recursive content equality.
For an equality or hash failure, reduce the case in this order:
- Compare each scalar member and the runtime record types.
- Test reference-valued members with both
EqualsandReferenceEquals. - Record the hash before insertion and after every permitted mutation.
- Construct an equivalent value independently instead of copying the first instance.
For a copy failure, draw two outer objects and one node for every referenced member. After with, connect each output member to the object it actually references. This small ownership sketch usually exposes a shared list or a cached value copied from the old input.
| Symptom | First fact to inspect |
|---|---|
| Equal-looking records compare unequal | Member equality and runtime record type |
| A dictionary cannot find its key | Hash-relevant mutation after insertion |
| Original changes after copying | Shared reference-valued members |
| Derived value disagrees with inputs | Stored initializer copied before with updates |
Compiler-generated members are visible through reflection and decompilers, but diagnostics should begin with public semantics. Generated member names and lowered implementation details can change; equality results, copy sharing, runtime type, and observable property values are the durable contract to test.
Further reading
5 questions · 2 predict-the-output · 1 spot-the-bug