AlgoMaster Logo

Record Types

Last Updated: May 17, 2026

9 min read

A record is a special kind of type in C# built for holding data. It looks like a class, behaves like a class in most situations, but flips two important defaults: it compares by value instead of by reference, and its properties are immutable out of the box. This lesson covers the basics, what a record is, how to declare one, how equality changes, and when to pick a record over a class.

Why Records Exist

Classes are the default tool for everything in C#, but they're a poor fit when all you want is to carry a few pieces of data around. Two cart-summary objects with the same numbers should probably count as equal. A shipping address that's been built once shouldn't be silently mutated by some method three layers down. And writing a ToString() override, an Equals() override, and a GetHashCode() override every time you need a "data class" is a lot of boilerplate.

Records solve all three problems in one declaration. They were added in C# 9 (.NET 5) specifically for this kind of data-shaped type, and they've become the standard choice for DTOs, configuration objects, query results, and any small immutable bundle of values.

Look at the difference. Here's a class that holds a product:

And the equivalent record:

One line. Same data, immutable properties, plus a working Equals(), GetHashCode(), and ToString() you didn't have to write. That's the pitch.

Declaring a Record

There are two ways to declare a record. The first is the positional form, which packs the property list right into the header:

The parameters in (string Name, decimal Price) aren't constructor parameters in the usual sense, they're shorthand. The compiler turns each one into a public init-only property and generates a constructor that takes them in order. You get Name and Price as readable properties on every instance, and a new Product("...", 79.99m) call works straight away.

The second form is the class-like body, which spells everything out:

The body form is closer to a regular class declaration. Each property is written out with { get; init; }. The init keyword (instead of set) is what makes the property settable only during object initialization, never again after. Object initializer syntax (new Product { Name = "...", Price = ... }) is how you populate them.

Both forms produce records with the same behavior. Pick the positional form when the record is purely data, three to six fields, and reads naturally as a tuple of parameters. Pick the body form when you want default values, custom validation in a setter, or extra properties that don't belong in the constructor.

The keyword record by itself is shorthand for record class. You can write either:

The longer form exists because C# 10 also added record struct, which is a record built on top of a struct instead of a class. For now, "record" means "reference type with value-based equality."

Value-Based Equality

This is the headline feature. Two classes are equal only if they're literally the same object in memory (the same reference). Two records are equal if all of their property values match, regardless of whether they're the same instance.

Compare the two side by side:

c1 and c2 hold the same data but they're two separate objects on the heap. The default Equals() for a class is reference equality, so it says "different objects, not equal." The == operator on a class also defaults to reference equality.

r1 and r2 are also two separate objects, but the record compares them field by field. Both have Name = "Mouse" and Price = 24.99m, so they're equal. The == operator is overloaded on records to use the same value-based logic.

That's a big shift in mental model. With records, you can treat two instances with identical data as interchangeable. That's what you want for things like cart line items, search filters, coordinates, or money amounts.

The equality check covers every property the compiler can see. Add a third property and it joins the comparison automatically:

Records also override GetHashCode() to be consistent with Equals(). That means you can put records in a HashSet<T> or use them as dictionary keys, and "same data" instances behave as "same key" instances. That doesn't work with regular classes unless you write your own GetHashCode() override.

Auto-Generated ToString()

A class's default ToString() returns the type's full name (Namespace.Product). That's almost never what you want when debugging. Records override ToString() to print the type name and every property value, formatted like a struct literal:

The format is consistent: TypeName { Property1 = Value1, Property2 = Value2 }. That makes records pleasant to log, dump to the console, or include in error messages. With a regular class, you'd see something like Namespace.Product and learn nothing useful.

The output uses each property's own ToString(). For strings, decimals, and primitives, that's what you'd expect. For nested records, the formatting recurses naturally:

Both layers print their own fields. No ToString() override required.

Immutability by Default

The properties on a record (positional or body-form) are init-only. That means you can set them when constructing the object and never again:

Uncomment the assignment line and the compiler stops the build with CS8852. The property has no setter at all after construction; the init accessor is callable only during the object initializer or the constructor.

Immutability is a quality that matters more than it first seems. When an object can't change after it's built, you can pass it around freely without worrying that some other piece of code is going to mutate it behind your back. You can use it as a dictionary key and trust that its hash code stays put. You can share it across threads without needing a lock.

Records make that the default. Classes make it possible (with init setters) but it's opt-in and easy to forget. For a data-shaped type, you almost always want the default that records give you.

A common follow-up question is "how do I change a value if records are immutable?" The answer is that you don't change the existing record, you build a new one based on it.

A First Look at with Expressions

When you need a "modified copy" of a record, C# provides the with keyword. It builds a new instance of the same record, copies every property over, and then overwrites the ones you specify:

original stays untouched. discounted is a brand-new Product with the same Name and a different Price. Anyone holding a reference to original still sees 79.99m, so there's no spooky-action-at-a-distance.

This pattern, building a new instance with one or two changes, is called non-destructive mutation. It's the everyday way you "update" record data. The Modern C# Features section goes much deeper into the mechanics (how the compiler implements with, what happens with derived records, when you'd write a custom copy constructor), but the basic idea fits here: records are immutable, and with is the tool that makes that practical.

record vs class: Side by Side

Both records and classes are reference types. Both live on the heap. Both can have properties, methods, constructors, and inheritance. The differences are mostly about defaults and intent.

Featureclassrecord
Equality (default Equals, ==)Reference equalityValue equality (compares all properties)
GetHashCode()Based on referenceBased on property values
ToString()Type name onlyTypeName { Prop1 = Val1, Prop2 = Val2 }
Property mutability (default)Mutable via get; set;Immutable via get; init;
Positional declarationNoYes (e.g., record Product(string Name, decimal Price);)
with expressionsNoYes
DeconstructionManual via Deconstruct methodAutomatic for positional records
StorageHeap (reference type)Heap (reference type)
InheritanceFrom any classOnly from other records
Mental modelObject with identity and behaviorData with values

The mental model line is the one that matters most when deciding. If you find yourself thinking "this object _is_ a customer" (identity, lifetime, behavior, can change over time), use a class. If you're thinking "this is the customer's data right now" (values, snapshot, equal when the data is equal), use a record.

A simple rule that holds up well: records hold data, classes do work. A record can have methods (and often should, for things like Subtotal() or IsExpired()), but its main job is being a value. A class can have data, but its main job is doing something.

When to Use a Record

Records shine in a few common situations:

  • DTOs (data transfer objects). API request and response shapes, message contracts, anything that's just a bag of values crossing a boundary.
  • Value types in the domain sense. Money, Address, DateRange, GeoPoint, OrderLineItem. Two of these are equal when their values are equal; identity doesn't apply.
  • Query results. When you read data out of a database or an API and want a strongly typed shape, records get you there with one line per type.
  • Configuration objects. Settings loaded once and shared, with no reason to mutate.
  • Cache keys. A record made from a few request parameters works as a Dictionary key out of the box because of value equality and value-based hashing.

When not to use a record:

  • The object has identity that matters even if its data is the same (two Customer objects with the same name and email are still different customers, because customers have IDs and lifetimes).
  • The object's data changes over time and you want callers to see those changes through a shared reference (a shopping Cart that grows as items are added).
  • The type has heavy behavior, not just data: services, repositories, controllers, handlers.
  • You need inheritance from an existing class (records can only inherit from other records).

Here's an e-commerce scenario that uses both. Product and Address are records (they're data, they should compare by value, they shouldn't mutate). Order is a class (it has state that changes as items are added and the status moves from Pending to Shipped):

The records carry data that doesn't change after creation. The Order class manages state, the list of items grows, the status moves from "Pending" to "Shipped", and Order is the natural owner of that mutable lifecycle. Mixing the two like this is the typical shape of a real codebase.

A Visual Comparison

A diagram helps to nail down where records differ from classes. Both produce instances on the heap, but the equality and immutability defaults pull them apart.

The split shows the three behavioral differences (equality, mutability, ToString) plus the one syntactic addition (with). Everything else, the constructor, methods, properties, inheritance from System.Object, allocation on the heap, is shared between the two. A record is a class with these specific defaults flipped, not a fundamentally different kind of type.

Summary

  • A record is a reference type with three flipped defaults: value-based equality, init-only properties, and an auto-generated ToString() that shows every property value.
  • The positional form (public record Product(string Name, decimal Price);) is the shortest declaration, generating properties, constructor, equality, hashing, and a readable ToString() from one line.
  • The body form (public record Product { public string Name { get; init; } = ""; }) is the longer alternative when you want default values or custom behavior on individual properties.
  • Two records are equal when every property value matches, regardless of whether they're the same instance. That makes them usable as Dictionary keys, in HashSet<T>, and with SequenceEqual out of the box.
  • Properties are immutable after construction. To "modify" a record, use a with expression to build a new instance with selected properties overwritten.
  • record is shorthand for record class; record struct exists too.
  • Pick a record when the type is data that compares by value and shouldn't mutate. Pick a class when the type has identity, mutable state, or behavior that defines what it is.
  • A typical e-commerce codebase uses both, records for Product, Address, Money, and DTOs; classes for Order, ShoppingCart, and services that do real work.