C# 9 Records Explained: Immutability, Value Equality and When to Use Them
Every non-trivial C# application is filled with objects whose only job is to carry data β API responses, domain events, configuration snapshots, query results. For years, developers wrote class after class, manually implementing Equals, GetHashCode, and ToString just to get predictable, safe data containers. That's hours of ceremony for something that should be a one-liner. C# 9, released with .NET 5 in November 2020, introduced Records to solve exactly this problem.
The core pain records fix is the gap between how we think about data objects and how classes actually behave. When you compare two class instances, C# asks 'are these the same object in memory?' β reference equality. But when you compare two shipping addresses or two money amounts, you want to ask 'do they contain the same values?' β value equality. Before records, achieving this required implementing four or five methods by hand every single time, and one typo in GetHashCode could cause subtle, hard-to-find bugs in collections.
By the end of this article you'll understand exactly what records are, how positional syntax and 'with' expressions work, when to reach for a record instead of a class or struct, and the three mistakes that trip up developers who are new to them. You'll also walk away knowing how to confidently answer the record questions that show up in C# interviews.
What a Record Actually Is (and What It Generates for You)
A record in C# 9 is a reference type β built on a class under the hood β but with a radically different set of defaults baked in by the compiler. When you declare a record, the compiler generates: structural equality (Equals and GetHashCode based on property values), a human-readable ToString, a protected copy constructor, and support for deconstruction. You get all of that for free.
The simplest record uses positional syntax: a single line where you list your properties inside parentheses. The compiler turns those into public init-only properties β meaning they can be set during object initialisation but never mutated afterwards. This immutability-by-default is the point. Records are designed to represent data that doesn't change after it's created.
Think of records as the right tool when your object IS its data. A Money value, an OrderId, a GPS coordinate, a User from an API response β these are records. A ShoppingCart that accumulates items, a DatabaseConnection that opens and closes β those are classes. The distinction is behavioural: records have data and identity through their values; classes have data plus mutable state and behaviour.
using System; // Positional record syntax β the compiler generates init-only properties, // a constructor, Equals, GetHashCode, ToString, and Deconstruct automatically. public record Money(decimal Amount, string Currency); // You can also use the verbose form if you need custom logic on a property. public record ProductSku { public string Code { get; init; } // init-only: set once, never changed public string Category { get; init; } public ProductSku(string code, string category) { // Validate in the constructor β records support this just like classes if (string.IsNullOrWhiteSpace(code)) throw new ArgumentException("SKU code cannot be empty.", nameof(code)); Code = code.ToUpperInvariant(); // normalise on the way in Category = category; } } class Program { static void Main() { var price = new Money(9.99m, "USD"); var samePriceAgain = new Money(9.99m, "USD"); var differentCurrency = new Money(9.99m, "GBP"); // Value equality β compares property values, not memory addresses Console.WriteLine(price == samePriceAgain); // True Console.WriteLine(price == differentCurrency); // False Console.WriteLine(price.Equals(samePriceAgain)); // True // Compiler-generated ToString shows all properties Console.WriteLine(price); // Money { Amount = 9.99, Currency = USD } // Deconstruction β unpack a record into individual variables var (amount, currency) = price; Console.WriteLine($"{amount} {currency}"); // 9.99 USD // Verbose-form record with validation var sku = new ProductSku("abc-123", "Electronics"); Console.WriteLine(sku.Code); // ABC-123 (normalised to uppercase) } }
False
True
Money { Amount = 9.99, Currency = USD }
9.99 USD
ABC-123
'with' Expressions: Updating Immutable Records Without the Pain
Immutability sounds great until you need to change one field. With a regular immutable class you'd have to call the constructor again and manually pass every single property β even the ones you're not changing. For a record with eight properties, that's eight arguments just to update one value. Nightmare.
C# 9 introduces the 'with' expression to solve this. It creates a new record instance that is a copy of the original, with only the properties you specify changed. The original is untouched. Under the hood, 'with' calls the compiler-generated protected copy constructor (sometimes called the clone constructor), then applies a set of property initialisers.
This pattern is everywhere in functional programming and is the backbone of state management systems like Redux. In C# it lets you model things like 'apply a discount to this price' or 'mark this order as shipped' without mutating the original object β making your code far easier to reason about, test, and debug. If a bug is reported with a specific order state, you can reproduce it exactly because that state never changed after it was created.
using System; public record ShippingAddress( string RecipientName, string Line1, string? Line2, string City, string PostalCode, string CountryCode ); public record Order( Guid OrderId, string Status, ShippingAddress Destination, decimal TotalAmount ); class Program { static void Main() { var originalAddress = new ShippingAddress( RecipientName: "Alice Johnson", Line1: "42 Elmwood Drive", Line2: null, City: "Portland", PostalCode: "97201", CountryCode: "US" ); var originalOrder = new Order( OrderId: Guid.NewGuid(), Status: "Pending", Destination: originalAddress, TotalAmount: 149.99m ); // 'with' creates a brand-new record β originalOrder is NEVER modified var dispatchedOrder = originalOrder with { Status = "Dispatched" }; // Apply a correction to just the postal code β everything else stays var correctedAddress = originalAddress with { PostalCode = "97202" }; // 'with' also works on nested records, but you must replace the whole nested record var reRoutedOrder = originalOrder with { Destination = originalAddress with { City = "Seattle", PostalCode = "98101" } }; Console.WriteLine($"Original status : {originalOrder.Status}"); // Pending Console.WriteLine($"Dispatched status: {dispatchedOrder.Status}"); // Dispatched Console.WriteLine($"Same OrderId? {originalOrder.OrderId == dispatchedOrder.OrderId}"); // True Console.WriteLine($"Same object? {ReferenceEquals(originalOrder, dispatchedOrder)}"); // False Console.WriteLine($"Original city : {originalOrder.Destination.City}"); // Portland Console.WriteLine($"Re-routed city : {reRoutedOrder.Destination.City}"); // Seattle // Value equality compares the full graph of properties var duplicate = originalOrder with { }; // identical copy Console.WriteLine($"Equal to duplicate? {originalOrder == duplicate}"); // True } }
Dispatched status: Dispatched
Same OrderId? True
Same object? False
Original city : Portland
Re-routed city : Seattle
Equal to duplicate? True
Records vs Classes vs Structs: Choosing the Right Tool
The question developers ask most often is 'when do I use a record instead of a class?' The honest answer comes down to three things: immutability, equality semantics, and size.
Use a record when your type IS its data β an immutable snapshot where two instances with identical values should be considered the same thing. API DTOs, domain value objects (Money, EmailAddress, DateRange), event sourcing events, command objects, and configuration snapshots are all textbook records.
Use a class when your type has mutable state, behaviour-heavy logic, or identity that's separate from its data. A UserSession, a DatabaseConnection, or a ShoppingCart accumulates state over time β the 'same' cart from a minute ago is still the same cart even if the items changed. That's a class.
Use a struct when you need stack allocation for small, frequently-created value types β think Vector2, RGBA colour, or a cache key. C# 10 introduced record structs if you want value semantics plus records' equality machinery, but for C# 9 the sweet spot is: structs for tiny, perf-critical value types; records for immutable data objects; classes for everything that has mutable state and behaviour.
using System; using System.Collections.Generic; // RECORD β value equality, immutable, great for DTOs and value objects public record EmailAddress(string Value) { // Custom validation in a compact primary constructor body public EmailAddress(string value) : this(value) { if (!value.Contains('@')) throw new ArgumentException("Not a valid email address.", nameof(value)); } } // CLASS β reference equality, mutable state, represents a 'thing' with a lifecycle public class UserSession { public Guid SessionId { get; } = Guid.NewGuid(); public string UserName { get; set; } // can change during the session public List<string> VisitedPages { get; } = new(); public UserSession(string userName) => UserName = userName; public void NavigateTo(string page) => VisitedPages.Add(page); } class Program { static void Main() { // --- Record behaviour --- var email1 = new EmailAddress("alice@example.com"); var email2 = new EmailAddress("alice@example.com"); // Same value β considered equal. Perfect for use as dictionary keys. Console.WriteLine($"Emails equal: {email1 == email2}"); // True var emailLookup = new Dictionary<EmailAddress, string> { [email1] = "Alice Johnson" }; // email2 works as the lookup key because it has the same value Console.WriteLine($"Name: {emailLookup[email2]}"); // Alice Johnson // --- Class behaviour --- var session1 = new UserSession("alice"); var session2 = new UserSession("alice"); // Different objects in memory β NOT equal, even with same data Console.WriteLine($"Sessions equal: {session1 == session2}"); // False // Classes are designed for mutable, evolving state session1.NavigateTo("/dashboard"); session1.NavigateTo("/orders"); Console.WriteLine($"Pages visited: {session1.VisitedPages.Count}"); // 2 } }
Name: Alice Johnson
Sessions equal: False
Pages visited: 2
Inheritance and Record Hierarchies: Powerful but with Limits
Records support single-level inheritance, and this is where they really shine for modelling domain events or discriminated-union-style hierarchies. A base record can hold shared properties, and derived records add specifics. The 'with' expression and equality both respect the actual runtime type β a crucial detail that catches people out.
Records cannot inherit from classes (other than object), and classes cannot inherit from records. The inheritance chain must be all records. This keeps the equality contract consistent β you'd get bizarre results if a record's Equals method had to compare itself against an arbitrary class hierarchy.
When you compare two records for equality, the runtime type must match. A base record instance is NOT equal to a derived record instance, even if all the base properties are identical. This is the correct behaviour β they represent different concepts β but it surprises developers who are coming from manually written Equals implementations that check only specific properties.
using System; // Base record β shared properties for all payment events public abstract record PaymentEvent( Guid PaymentId, DateTime OccurredAt, decimal Amount ); // Derived records β each adds its own context public record PaymentInitiated( Guid PaymentId, DateTime OccurredAt, decimal Amount, string PayerEmail // extra field for this event type ) : PaymentEvent(PaymentId, OccurredAt, Amount); public record PaymentSucceeded( Guid PaymentId, DateTime OccurredAt, decimal Amount, string TransactionReference // extra field for this event type ) : PaymentEvent(PaymentId, OccurredAt, Amount); public record PaymentFailed( Guid PaymentId, DateTime OccurredAt, decimal Amount, string FailureReason ) : PaymentEvent(PaymentId, OccurredAt, Amount); class Program { static void ProcessEvent(PaymentEvent evt) { // Pattern matching on records is clean and exhaustive var description = evt switch { PaymentInitiated e => $"Payment started by {e.PayerEmail}", PaymentSucceeded e => $"Payment confirmed. Ref: {e.TransactionReference}", PaymentFailed e => $"Payment failed: {e.FailureReason}", _ => "Unknown payment event" }; Console.WriteLine($"[{evt.OccurredAt:HH:mm:ss}] ${evt.Amount:F2} β {description}"); } static void Main() { var paymentId = Guid.NewGuid(); var now = DateTime.UtcNow; var initiated = new PaymentInitiated(paymentId, now, 49.99m, "bob@example.com"); var succeeded = new PaymentSucceeded(paymentId, now.AddSeconds(2), 49.99m, "TXN-8821"); var failed = new PaymentFailed(paymentId, now, 49.99m, "Card declined"); ProcessEvent(initiated); ProcessEvent(succeeded); ProcessEvent(failed); // IMPORTANT: type matters for equality // Even though all base properties match, these are DIFFERENT types PaymentEvent asBase = initiated; var anotherInitiated = new PaymentInitiated(paymentId, now, 49.99m, "bob@example.com"); Console.WriteLine($"Same type, same values: {initiated == anotherInitiated}"); // True // Casting doesn't change what the object IS Console.WriteLine($"Base ref == derived: {asBase == anotherInitiated}"); // True (same type at runtime) } }
[14:22:10] $49.99 β Payment confirmed. Ref: TXN-8821
[14:22:08] $49.99 β Payment failed: Card declined
Same type, same values: True
Base ref == derived: True
| Feature / Aspect | Record (C# 9) | Class | Struct |
|---|---|---|---|
| Memory allocation | Heap (reference type) | Heap (reference type) | Stack (value type) |
| Default equality | Value (property-by-property) | Reference (same object?) | Value (field-by-field) |
| Immutability | Init-only by default | Mutable by default | Mutable by default |
| 'with' expression | Built-in, compiler-generated | Not supported natively | Supported in record struct (C# 10) |
| ToString output | Auto: Type { Prop = val, ... } | Type name only (needs override) | Type name only (needs override) |
| Inheritance | Records only, no classes | Full class hierarchy | No inheritance allowed |
| Best for | DTOs, value objects, events | Stateful, behaviour-rich objects | Tiny, perf-critical value types |
| Null in equality | Null-safe (generated code) | Null handling is manual | Cannot be null (non-nullable) |
| Deconstruction | Auto-generated for positional | Must implement manually | Must implement manually |
π― Key Takeaways
- Records use value equality by default β two record instances with identical property values ARE equal, making them safe dictionary keys and reliable in collections without any extra code.
- The 'with' expression creates a shallow copy with specified properties changed β the original record is never mutated, but nested mutable reference types (like List
) are still shared between the original and the copy. - Choose records for immutable data snapshots (DTOs, value objects, domain events); choose classes for objects with evolving state and identity that's separate from their data.
- Record inheritance respects runtime types in equality β a base record reference and a derived record instance with matching base properties are equal ONLY if they're the same runtime type, enforced via a generated EqualityContract property.
β Common Mistakes to Avoid
- βMistake 1: Treating 'init-only' as 'truly immutable' when properties hold mutable reference types β If a record property is a List
, the list's contents can still be changed after construction (list.Add('surprise')). The record doesn't own an immutable list, it holds a reference to a mutable one. Fix: use ImmutableList from System.Collections.Immutable, or ReadOnlyCollection when exposing collections from records. - βMistake 2: Expecting a base record and a derived record to be equal when their base properties match β If PaymentInitiated and PaymentSucceeded share the same PaymentId, Amount and OccurredAt, beginners expect them to be equal. They're not β Equals checks EqualityContract (the runtime type) first. Fix: compare only the specific type you intend, or extract shared properties and compare those explicitly via a method.
- βMistake 3: Using records for objects that need to change frequently in a hot path β every 'with' expression allocates a new object on the heap. If you're doing thousands of state updates per second (game loop, real-time telemetry), the GC pressure from record 'with' chains will hurt performance. Fix: for high-frequency mutable state, use a class or a mutable struct. Reserve records for data that genuinely doesn't change after creation.
Interview Questions on This Topic
- QWhat is the difference between a record and a class in C# 9, and can you give a concrete example of when you'd choose each one?
- QHow does the 'with' expression work under the hood β what does the compiler actually generate, and what are its limitations with nested mutable objects?
- QIf you have a base record and a derived record with identical values in the base properties, will they be equal? Why or why not β and what is EqualityContract?
Frequently Asked Questions
Are C# records immutable?
Positional records use init-only properties, which means they can't be reassigned after the object is constructed β but this isn't the same as deep immutability. If a property holds a mutable type like List
Can a record inherit from a class in C# 9?
No. Records can only inherit from other records (or from object implicitly). A class cannot inherit from a record either. This restriction exists to keep the equality contract consistent β mixing class and record hierarchies would make value equality unpredictable.
Is a record a value type or a reference type in C# 9?
A record in C# 9 is a reference type β it's allocated on the heap and passed by reference, just like a class. It behaves like a value type in terms of equality (comparing by property values), but it IS a reference type. C# 10 introduced 'record struct' if you need value-type semantics with record features.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful β not just SEO filler.