← Open in the full interactive course (progress tracking, search & more)

A class asks "is this the same object?" A record asks "does this hold the same data?" — and for a lot of modern C#, that second question is the one you actually care about.

Say you have two Point objects, each with X = 3, Y = 4. Are they equal? If Point is an ordinary class, the answer — by default — is no. C# classes compare by reference: two instances are only "equal" if they're literally the same object in memory, even if every field inside them is identical.

That surprises a lot of people the first time they hit it, because for plain data — a coordinate, a money amount, a snapshot of an order at a point in time — "equal" obviously should mean "holds the same values," not "is the same object." Getting that right with a plain class means hand-writing Equals, GetHashCode, and usually a readable ToString too — for every single data type in your codebase.

In this lesson, you'll learn what records are, how record/record class/record struct differ, why they give you value-based equality for free, and how with expressions let you create modified copies without ever mutating the original.

What Is It?

The Simple Explanation

A record is a special kind of type, purpose-built for modeling data rather than behavior. You declare its shape once — the properties it holds — and the compiler automatically writes the boring-but-important plumbing for you: equality that compares actual values, a readable string representation, and an easy way to make a modified copy without touching the original.

The Technical Definition

A record (C# 9) is a reference type declared with the record (or explicitly record class) keyword that the compiler augments with synthesized value-based equality (Equals, GetHashCode, ==/!=), a synthesized ToString that prints all property values, a synthesized Deconstruct method, and support for with expressions that produce a copy with one or more properties changed. A record struct (C# 10) applies the same synthesized members to a value type instead of a reference type, when you want records' ergonomics with struct's stack-allocation and copy-by-value semantics.

class (default)

record / record class

Why Does It Exist?

The Problem

Modeling a plain data type as a class means hand-rolling a lot of repetitive machinery to make it behave the way data intuitively should:

public class Point
{
    public int X { get; }
    public int Y { get; }

    public Point(int x, int y) { X = x; Y = y; }

    public override bool Equals(object? obj) =>
        obj is Point other && X == other.X && Y == other.Y;

    public override int GetHashCode() => HashCode.Combine(X, Y);

    public override string ToString() => $"Point {{ X = {X}, Y = {Y} }}";
}

That's a lot of boilerplate for a type that's just "two numbers." And every data type in your codebase — an order snapshot, a money value, a coordinate — needs the same treatment, over and over, or you get subtly wrong equality (two identical-looking objects that Equals says are different) and unhelpful debug output (ToString() just prints the type's namespace-qualified name).

The Need

Developers needed a concise way to say "this type represents data — please generate the standard, correct value-comparison plumbing for me" without writing it by hand every time, and a clean way to produce a modified copy of an immutable value without a wall of "copy constructor" boilerplate either.

The Solution

Records. The exact same Point above, as a record:

public record Point(int X, int Y);

One line. Value-based equality, a readable ToString, and with-expression support are all included automatically.

Big Picture

CLASS EQUALITY vs RECORD EQUALITY

class — reference equality

var a = new PointClass(3, 4);
var b = new PointClass(3, 4);
a == bfalse
(different objects in memory)

record — value equality

var a = new PointRecord(3, 4);
var b = new PointRecord(3, 4);
a == btrue
(same data, so they're "equal")

How It Works

DECLARING AND USING A RECORD — STEP BY STEP
1. DECLARE THE RECORD
public record Point(int X, int Y);
2. CREATE INSTANCES LIKE ANY OTHER TYPE
var p1 = new Point(3, 4);
var p2 = new Point(3, 4);
3. GET VALUE-BASED EQUALITY FOR FREE
Console.WriteLine(p1 == p2);       // True — same X and Y
Console.WriteLine(p1.Equals(p2));  // True
Console.WriteLine(p1);             // "Point { X = 3, Y = 4 }" — readable, no override needed
4. MODIFY VIA A COPY WITH with
var p3 = p1 with { Y = 10 }; // a NEW Point(3, 10) — p1 is untouched

Console.WriteLine(p1); // still "Point { X = 3, Y = 4 }"
Console.WriteLine(p3); // "Point { X = 3, Y = 10 }"

Simple Example

// record class — reference type, ideal for most data models
public record Money(decimal Amount, string Currency);

// record struct — value type, ideal for small, frequently-copied values
public readonly record struct Coordinate(double Latitude, double Longitude);

var price = new Money(19.99m, "USD");
var discounted = price with { Amount = 14.99m };

Console.WriteLine(price);      // Money { Amount = 19.99, Currency = USD }
Console.WriteLine(discounted); // Money { Amount = 14.99, Currency = USD }
Console.WriteLine(price == discounted); // False — different Amount

var here = new Coordinate(51.5074, -0.1278);
var there = new Coordinate(51.5074, -0.1278);
Console.WriteLine(here == there); // True — record structs get value equality too

Meaning: Money and Coordinate both get equality-by-value, readable printing, and with-based copying — Money as a reference type (a normal object on the heap), Coordinate as a value type (copied by value, like any struct), with the same generated members either way.

Real-World Example

Auditing is a natural home for records: you want an immutable snapshot of "what the order looked like at this exact moment," and you want to compare two snapshots to see if anything actually changed.

public record OrderSnapshot(
    int OrderId,
    string Status,
    decimal Total,
    DateTime CapturedAt);

public class AuditLog
{
    private readonly List<OrderSnapshot> _history = new();

    public void Capture(OrderSnapshot snapshot) => _history.Add(snapshot);

    public bool HasChangedSince(OrderSnapshot current, OrderSnapshot previous) =>
        current with { CapturedAt = default } != previous with { CapturedAt = default };
        // ignore the timestamp when comparing — we only care whether the DATA changed
}

// ─── Usage ───
var snapshot1 = new OrderSnapshot(5001, "Pending", 89.99m, DateTime.UtcNow);
audit.Capture(snapshot1);

// Later, the order ships — capture a new snapshot instead of mutating the old one
var snapshot2 = snapshot1 with { Status = "Shipped", CapturedAt = DateTime.UtcNow };
audit.Capture(snapshot2);

// snapshot1 is completely untouched — it's still "Pending", exactly as it was recorded

Because with never mutates the original, snapshot1 remains a trustworthy historical record even after snapshot2 is created from it — which is exactly the guarantee an audit trail needs. And because equality compares values, you can directly ask "did anything meaningful change between these two snapshots?" without writing a manual field-by-field comparison.

Analogy

Two identical receipts vs two people

Two people can look identical (twins, say) but they're still two different, distinct people — that's reference equality, the class default: "are you literally the same individual?"

Two printed receipts with the same items, prices, and total are, for every practical purpose, the same receipt — it genuinely doesn't matter that they're two separate pieces of paper. That's value equality, the record default: "do you represent the same information?" And a with expression is like photocopying a receipt and crossing out one line to correct it — you get a new piece of paper with the fix, and the original stays exactly as it was, unaltered, for the record.

Under the Hood

WHAT THE COMPILER ACTUALLY GENERATES
A ONE-LINE RECORD EXPANDS INTO REAL, COMPILED MEMBERS

Behind public record Point(int X, int Y);, the compiler synthesizes roughly this:

public class Point : IEquatable<Point>
{
    public int X { get; init; }
    public int Y { get; init; }

    public Point(int x, int y) { X = x; Y = y; }

    public override bool Equals(object? obj) => Equals(obj as Point);
    public bool Equals(Point? other) =>
        other is not null && X == other.X && Y == other.Y;
    public override int GetHashCode() => HashCode.Combine(X, Y);
    public override string ToString() => $"Point {{ X = {X}, Y = {Y} }}";

    public void Deconstruct(out int x, out int y) { x = X; y = Y; }

    // a hidden "copy constructor" and a Clone-style method back the `with` expression
    protected Point(Point original) { X = original.X; Y = original.Y; }
}

A with expression compiles to a call to that generated copy mechanism, followed by setting whichever init properties you specified — it's ordinary generated C#, not a special runtime feature.

Common Confusion

1. "Records are immutable" — not automatically, but strongly encouraged to be

A positional record's generated properties use init accessors, which means they can only be set during object initialization — not reassigned afterward. But you can write a record with regular mutable { get; set; } properties if you choose to. Records don't force immutability; they default you toward it, because init plus with is by far the most common and idiomatic way to use them.

2. record vs record class — identical, just spelled differently

record Point(...) and record class Point(...) mean exactly the same thing — class is implied by default. The explicit form exists mainly to make the intent unambiguous next to record struct, especially in code where both appear.

3. with does a shallow copy, not a deep one

If a record holds a reference to a mutable object (say, a List<string>), with copies the reference to that list, not a brand-new list. Mutating the list through the copy would also affect the original, because both records point at the same underlying list object. For genuinely deep immutability, use immutable collection types (like ImmutableList<T>) for any collection-typed properties.

Common Mistakes

Mistake 1 — Using a record for a type that genuinely needs identity and mutable state

Modeling a long-lived, stateful service or entity (say, a live database connection wrapper, or a domain entity tracked by a unique ID where two objects with the same field values should still be treated as separate) as a record, and getting surprised by value-based equality.

Use a class for anything where "is this the same object/entity" matters more than "does this hold the same data" — records are for data, not identity.

Mistake 2 — Assuming with deep-copies everything

public record Cart(List<string> Items);

var cart1 = new Cart(new List<string> { "Book" });
var cart2 = cart1 with { }; // "copy" — but Items is the SAME list reference

cart2.Items.Add("Pen");
Console.WriteLine(cart1.Items.Count); // 2 — cart1 was affected too!

For collection-typed properties you want truly independent, either copy the collection explicitly in the with expression (cart1 with { Items = new List<string>(cart1.Items) }) or use an immutable collection type.

Mistake 3 — Forgetting record structs still copy by value everywhere, like any struct

A record struct gets all the record ergonomics (value equality, ToString, with) but it's still a value type — passing it to a method, or assigning it to another variable, copies the whole thing. For a small type like a coordinate that's fine and often desirable; for a record with many fields, that copying can add measurable overhead. Choose record (reference type) for larger data, record struct for small, frequently-copied values.

When Should I Use It?

Rule of thumb: If you'd naturally describe two instances as "the same" whenever their data matches — think Money, a Point, a DTO — reach for a record. If two instances with identical data should still be considered distinct — think a Customer entity tracked by ID, a database connection — use a class.

Mental Model

class = "Am I the exact same object?" (identity)
record = "Do I hold the exact same data?" (value)
with = "Give me a new one, just like this, except..." — never mutates the original.

Remember: Records are classes (or structs) with generated value-equality, printing, and copy-with-changes plumbing — they're not a fundamentally new kind of type, just a much more concise way to declare a data-shaped one.

Key Takeaway


Check Your Understanding

You've seen how records give you value equality and non-destructive copying essentially for free. Let's check your understanding.

1. Given public record Point(int X, int Y); and var a = new Point(1, 2); var b = new Point(1, 2);, what does a == b evaluate to, and why?

Show answer

Correct: B

Why B is correct: The compiler automatically generates value-based Equals/== for records, comparing every property. Since both a and b have X = 1, Y = 2, they're equal.

Why A is incorrect: That's how a plain class behaves by default — records are specifically designed to compare by value instead.

Why C is incorrect: Records fully support ==, generated automatically alongside Equals.

Why D is incorrect: No manual override is needed — the value-equality members are synthesized automatically just from the record declaration.

Reinforcement: Value-based equality is a defining, automatic feature of records — it's the main reason they exist.

2. What happens to the original record when you use a with expression on it?

Show answer

Correct: B

Why B is correct: with is non-destructive by design — it copies the original record's values into a new instance, then applies only the properties you specify in the braces. The source record is never touched.

Why A is incorrect: Records don't get mutated by with — that would defeat the point of using records for immutable data in the first place.

Why C is incorrect: Nothing is deleted; both the original and the new copy continue to exist independently (subject to normal garbage collection once nothing references them).

Why D is incorrect: For a record (reference type), with creates a genuinely new, separate object — not a shared reference to the same one.

Reinforcement: "Non-destructive mutation" means you get a changed value without ever altering the thing you started with.

3. A record has a property of type List<string>. After using with to create a copy, you add an item to the copy's list. What happens to the original record's list?

Show answer

Correct: B

Why B is correct: with copies property values, but for a reference-typed property like List<string>, the "value" being copied is the reference itself — both records end up pointing at the same list object. Mutating the list through one record is visible through the other.

Why A is incorrect: Records perform a shallow copy, not a deep one — this is one of the most important nuances to understand about with.

Why C is incorrect: Sharing a reference between two records is perfectly legal and doesn't throw; it's just a behavior developers need to be aware of.

Why D is incorrect: Nothing about with makes a shared collection read-only — it remains fully mutable through either reference.

Reinforcement: For genuinely independent collections after a with, copy the collection explicitly or use an immutable collection type.

4. You're modeling a Customer entity where two customers with identical names and emails should still be treated as two separate people (each has a unique database ID and independent, mutable state). Should this be a record or a class?

Show answer

Correct: B

Why B is correct: Records are built for value-based comparison, which is wrong for an entity where two instances with the same field values should still be distinct (e.g. two different customers who happen to share a name). A class's default reference equality models that identity-based relationship correctly.

Why A is incorrect: "Modern" doesn't mean "always correct" — records solve a specific problem (data equality), and using them where identity matters produces genuinely wrong equality behavior.

Why C is incorrect: A record struct would still use value equality, which is the wrong semantics here, and entities with mutable state and identity are also a poor fit for a value type in general.

Why D is incorrect: This is precisely the core difference between the two — records compare by value, classes compare by reference, by default.

Reinforcement: Choosing record vs class is a design decision about whether identity or data-equality is the right notion of "sameness" for that type.

You now understand why records exist and when value-based equality is exactly the right tool for the job.


dotnetmadeeasy.com — Learn C# and .NET, the right way.