record struct and readonly record struct: semantics, equality performance, default values and when to choose them.
A record struct (C# 10) is a value type with record features: generated Equals, GetHashCode, ==, ToString and, for positional ones, Deconstruct. Unlike a record class, its positional properties are mutable (get; set;) by default; readonly record struct makes them init and the struct readonly, which is what you usually want. Its generated equality compares fields directly, so it avoids the reflection and boxing that a plain struct's inherited ValueType.Equals can fall back to. It has no EqualityContract and no inheritance. Watch the default value: default(OrderId) bypasses constructors and validation. Use it for small, immutable identifiers and values (ids, coordinates, money) used in hot paths or as dictionary keys.
A record struct (C# 10) is a value type with record features generated: value equality via IEquatable<T>, ==/!=, ToString, Deconstruct and with. It combines the no-allocation benefit of structs with fast, correct generated equality. I'd compare it with record classes and plain structs, explain readonly record struct, the mutability default, with, the default value problem, and when it's the right choice.
public record struct Point(int X, int Y); // mutable positional properties (get; set;)
public readonly record struct OrderId(Guid Value); // immutable (get; init;), readonly struct
public readonly record struct Money(decimal Amount, string Currency)
{
public static Money Aud(decimal amount) => new(amount, "AUD");
}
record (class) |
record struct |
plain struct |
|
|---|---|---|---|
| Kind | Reference type | Value type | Value type |
| Allocation | Heap per instance | Inline (none by itself) | Inline |
| Positional properties | init |
set (mutable) unless readonly |
N/A |
| Equality | Generated, includes EqualityContract |
Generated IEquatable<T>, field-wise |
ValueType.Equals (reflection/bitwise, boxes) |
== / != |
Generated | Generated | Not available unless you define it |
GetHashCode |
Generated | Generated (all fields) | ValueType.GetHashCode (may use only first field) |
ToString |
Generated | Generated | Type name |
| Inheritance | Records only | No | No |
| Null | Can be null | Can't (use T?) |
Can't |
with |
Yes | Yes | Yes (C# 10 for any struct) |
readonly record struct and the mutability defaultA plain record struct's positional properties are mutable ({ get; set; }) — chosen for consistency with tuples and struct conventions. That reintroduces mutable-struct bugs (mutating copies; changing hash codes of dictionary keys). readonly record struct makes positional properties init-only and the struct readonly, which:
in or stored in readonly fields,My default is readonly record struct.
ValueType.EqualsA plain struct's Equals(object) is ValueType.Equals: it boxes the argument and, if the struct contains references or padding, uses reflection to compare fields — slow; GetHashCode may only hash the first field, causing poor distribution. A record struct generates strongly typed Equals(T) using EqualityComparer<TField>.Default per field and a full GetHashCode — fast, no boxing, good hashing. That's the main reason to prefer record structs (or hand-written IEquatable<T>) for value types used in collections.
with expressions on structsvar p2 = p1 with { Y = 10 }; // copies the struct (by value), then sets Y
For structs, with is just a value copy plus property sets — cheap, no allocation. Since C# 10 with works on any struct, not just record structs.
default(OrderId) is OrderId(Guid.Empty) and default(Money) is Money(0, null) — created without running any constructor: from default, array elements, uninitialised fields, missing JSON properties, EF Core key sentinels. Positional parameters can't prevent it. Mitigations:
Money.Zero), or provide IsDefault/IsEmpty checks.Guid.Empty in validation.Currency ?? "AUD").CustomerId, OrderId) — no allocation, value equality, prevents mixing IDs.DateRange of DateOnlys, Money (decimal + short code).(Region, Year) with names).Not a fit: large data (copy cost), types needing inheritance, types frequently boxed (interfaces/object), or values whose default state is dangerous and can't be handled.
An order system passed IDs as raw Guids, and a bug passed a customerId where an orderId was expected — the query returned nothing and a refund silently failed. Introducing readonly record struct OrderId(Guid Value) and CustomerId(Guid Value), with EF Core value converters and System.Text.Json converters, made such mix-ups compile errors with no allocation overhead. We added a validation rule rejecting default IDs at API boundaries.
The interviewer checks your understanding of value types and record features together.
Green flags
readonly.ValueType.Equals cost and why generated equality is better.Red flags
default(T).readonly record struct Money(decimal Amount, string Currency)public readonly struct Money : IEquatable<Money>
{
public Money(decimal Amount, string Currency) { this.Amount = Amount; this.Currency = Currency; }
public decimal Amount { get; init; }
public string Currency { get; init; }
public bool Equals(Money other) =>
EqualityComparer<decimal>.Default.Equals(Amount, other.Amount) &&
EqualityComparer<string>.Default.Equals(Currency, other.Currency);
public override bool Equals(object? obj) => obj is Money m && Equals(m);
public override int GetHashCode() => HashCode.Combine(Amount, Currency); // conceptually
public static bool operator ==(Money l, Money r) => l.Equals(r);
public static bool operator !=(Money l, Money r) => !l.Equals(r);
public override string ToString() { ... }
public void Deconstruct(out decimal Amount, out string Currency) { ... }
}
No EqualityContract (no inheritance), no copy constructor/Clone (struct copy semantics).
Money = 16 bytes (decimal) + 8 bytes (string reference) = 24 bytes — copying is cheap but not free; pass by value is fine for most uses. OrderId(Guid) = 16 bytes — ideal.
builder.Property(o => o.Id).HasConversion(id => id.Value, value => new OrderId(value));
Or convention-based converters via ConfigureConventions. For keys generated by the database, configure value generation and sentinel values appropriately. Libraries like StronglyTypedId and Vogen generate IDs with converters (and can enforce non-default creation).
System.Text.Json serialises record structs as objects ({"value":"..."}) by default; for IDs you usually want a custom JsonConverter<OrderId> to serialise as a plain string/GUID.
Record structs implement interfaces (IEquatable<T>, possibly your own). Passing them as interface types or object boxes them. Use generics with constraints in hot paths.
Dictionary<PlainStruct, int> vs Dictionary<RecordStruct, int> lookups.List<T> via indexer and observe the compile error / copy issue.default(OrderId) and an array of IDs and inspect values.public readonly record struct CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.NewGuid());
public bool IsEmpty => Value == Guid.Empty; // default(CustomerId) must be handled
public override string ToString() => Value.ToString("N")[..8];
}
public readonly record struct PriceKey(int ProductId, string Region);
public record struct MutablePoint(int X, int Y); // positional properties are get; set;
public struct PlainKey // no Equals: falls back to ValueType.Equals
{
public int ProductId;
public string Region;
}
public static class Program
{
public static void Main()
{
var prices = new Dictionary<PriceKey, decimal>
{
[new PriceKey(42, "NSW")] = 19.95m,
[new PriceKey(42, "WA")] = 21.50m,
};
Console.WriteLine(prices[new PriceKey(42, "WA")]); // fast, allocation-free equality
var p = new MutablePoint(1, 2);
p.X = 10; // allowed: not readonly
var q = p with { Y = 20 }; // with works on structs
Console.WriteLine($"{p} {q}");
CustomerId[] ids = new CustomerId[2]; // default values, constructor never ran
Console.WriteLine(ids[0].IsEmpty); // True
var (product, region) = new PriceKey(7, "VIC"); // Deconstruct generated
Console.WriteLine($"{product} {region}");
}
}
record structWhat happens: positional record structs mutated after creation.
Why it is wrong: mutable struct bugs and changing hash codes.
Do instead: readonly record struct.
What happens: many fields in a record struct. Why it is wrong: copying cost on every assignment and call. Do instead: record classes for larger data.
What happens: default(OrderId) flows into queries or saves.
Why it is wrong: invalid IDs bypass constructors.
Do instead: validate at boundaries; configure EF sentinels.
What happens: record structs passed as object or interfaces.
Why it is wrong: allocations negate struct benefits.
Do instead: generics with constraints.
What happens: structs without IEquatable<T> used as keys.
Why it is wrong: slow reflection equality and poor hashing.
Do instead: record structs or IEquatable<T> implementations.
What happens: trying to derive record structs. Why it is wrong: structs can't inherit. Do instead: composition or record classes.
What happens: IDs serialised as {"value": ...} objects.
Why it is wrong: awkward API contracts.
Do instead: custom JSON converters.
What happens: Guid for every ID.
Why it is wrong: IDs can be mixed up silently.
Do instead: strongly typed readonly record struct IDs.
Record structs make it cheap to do two things that senior engineers care about: type safety for primitives and allocation-free value semantics. Strongly typed ids prevent a whole class of argument-swap bugs at zero run-time cost.
| Need | Choice |
|---|---|
| Value object, may be null, inheritance | record class |
| Small immutable value, hot path, dictionary key | readonly record struct |
| Tuple-like temporary grouping | value tuple |
| Large data with identity | class |
The trade-off is integration work: serialisers, EF Core and model binding need converters for custom id types. A senior answer mentions that cost and plans for it (a shared converter library) rather than discovering it per project.
A struct is a value type: a variable holds the data itself, assignment and parameter passing copy it, and it is stored inline (in a local, a field or an array element) rather than as a separate heap object. Structs cannot inherit (they are sealed) but can implement interfaces. default(T) always gives all-zero fields, even when you write a parameterless constructor (allowed since C# 10). readonly struct guarantees immutability and avoids defensive copies. ref struct (like Span<T>) can live only on the stack; since C# 13 it can implement interfaces and be used with allows ref struct generics. Converting a struct to object or an interface boxes it. Mutable structs cause lost updates because you often change a copy.
A record (record class) is a reference type for which the compiler generates value-based equality (Equals, GetHashCode, ==, IEquatable<T>), a virtual EqualityContract so a base and derived record are never equal, ToString via PrintMembers, a copy constructor used by with, and, for positional records, init properties and Deconstruct. with is a shallow copy: nested objects and lists are shared. Equality compares members with EqualityComparer<T>.Default, so a List<T> member is compared by reference, which breaks the "value" idea. Records fit DTOs, messages, events and value objects; they are a poor fit for EF Core entities, which need identity, not value equality, and are tracked by reference.
Boxing wraps a value type in a new heap object so it can be used as object or an interface; unboxing checks the object's type and copies the value back, and it must be the exact type or you get InvalidCastException. Every box is a heap allocation, so in hot paths it creates GC pressure. Hidden boxing comes from non-generic APIs like ArrayList and params object[], passing structs as interfaces, calling GetType() or non-overridden Equals/GetHashCode on structs, and enumerating a List<T> through IEnumerable<T>. I find it with BenchmarkDotNet's MemoryDiagnoser or an allocation profiler and fix it with generics, constraints and IEquatable<T>.
How to tell it (STAR)
**Situation:** our nightly pricing batch took [90] seconds and needed to fit a [30] second window. **Task:** I was asked to find quick wins without redesigning it. **Action:** I profiled it and found the dictionary key was a plain struct whose equality fell back to reflection and boxing. I changed it to a readonly record struct and enabled the CA1815 rule for other structs. **Result:** allocations in the lookup path dropped to zero and the batch ran in [28] seconds, with no logic changes.
Last updated 30 Oct 2026.