Records

Reviewed & published by Brayan K

Records are C#'s answer to "I just want a simple, immutable bundle of data". In a single line they give you a constructor, read-only properties, a readable ToString(), value-based equality and copy-and-modify with expressions — all the boilerplate you'd otherwise write by hand. They're the modern way to model data that flows through your program.

Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

Think of a printed receipt. Once it's printed, you don't scribble on it — if a price changes, the till prints a new receipt. Two receipts with identical lines are "the same" to a customer, even though they're separate pieces of paper. Records work exactly like that: they're immutable (print a new one with with to "change" it), and two records with the same contents are considered equal.

1. Declaring a Record

The positional syntax — record Person(string Name, int Age); — is the whole declaration. The compiler generates a constructor that takes Name and Age, creates matching read-only properties, and writes a tidy ToString(). That's a dozen lines of a hand-written class replaced by one.

using System;

// A record in ONE line. The compiler writes a constructor, properties,
// ToString(), equality, and more for you.
record Person(string Name, int Age);

class Program
{
    static void Main()
    {
        var alice = new Person("Alice", 30);

        // The properties are created automatically (and are read-only):
        Console.WriteLine(alice.Name);   // Alice
        Console.WriteLine(alice.Age);    // 30

        // Records get a nice ToString() for free:
        Console.WriteLine(alice);        // Person { Name = Alice, Age = 30 }
    }
}

// ✅ Expected output:
//    Alice
//    30
//    Person { Name = Alice, Age = 30 }

2. Value Equality

This is the headline feature. With a normal class, a == b asks "are these the same object in memory?". With a record, it asks "do they have the same values?". Two records built from identical data are equal, which is exactly what you want for data objects.

using System;

record Point(int X, int Y);

class Program
{
    static void Main()
    {
        var a = new Point(1, 2);
        var b = new Point(1, 2);   // same VALUES, different object in memory
        var c = new Point(3, 4);

        // Records compare by VALUE, not by reference.
        // Two records are equal if all their properties are equal.
        Console.WriteLine(a == b);        // True   (same X and Y)
        Console.WriteLine(a == c);        // False
        Console.WriteLine(a.Equals(b));   // True

        // A normal class would print False here (reference equality).
        Console.WriteLine(ReferenceEquals(a, b)); // False (different objects)
    }
}

// ✅ Expected output:
//    True
//    False
//    True
//    False

3. Immutability & with Expressions

Record properties are read-only after construction, which makes them safe to share and reason about. To "change" one, you don't mutate it — you create a copy with the change using a with expression. The original stays exactly as it was. This is called non-destructive mutation.

using System;

record Person(string Name, int Age, string City);

class Program
{
    static void Main()
    {
        var original = new Person("Maya", 28, "London");

        // Records are IMMUTABLE — you can't change a property after creation.
        // original.Age = 29;   // ❌ won't compile

        // Instead, 'with' makes a COPY with some properties changed:
        var older = original with { Age = 29 };
        var moved = original with { City = "Berlin", Age = 29 };

        Console.WriteLine(original); // Person { Name = Maya, Age = 28, City = London }
        Console.WriteLine(older);    // Person { Name = Maya, Age = 29, City = London }
        Console.WriteLine(moved);    // Person { Name = Maya, Age = 29, City = Berlin }

        // 'original' is untouched — non-destructive mutation.
    }
}

// ✅ Expected output:
//    Person { Name = Maya, Age = 28, City = London }
//    Person { Name = Maya, Age = 29, City = London }
//    Person { Name = Maya, Age = 29, City = Berlin }

Your turn — declare a Product record and make a discounted copy with with.

using System;

// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".

// 1) Declare a record called Product with a string Name and a decimal Price.
record Product(___);     // 👉  string Name, decimal Price

class Program
{
    static void Main()
    {
        var book = new Product("C# Handbook", 24.99m);

        // 2) Use 'with' to make a discounted copy at 19.99m
        var sale = book with { ___ };   // 👉  Price = 19.99m

        Console.WriteLine(book);   // Product { Name = C# Handbook, Price = 24.99 }
        Console.WriteLine(sale);   // Product { Name = C# Handbook, Price = 19.99 }
    }
}

4. Deconstruction & Patterns

Every positional record gets a free Deconstruct method, so you can unpack it into separate variables with var (a, b) = record;. Records also slot perfectly into the pattern matching you just learned — property patterns read straight off a record's properties.

using System;

record Coordinate(double Lat, double Lng);

class Program
{
    static void Main()
    {
        var london = new Coordinate(51.5074, -0.1278);

        // Records auto-generate a Deconstruct method, so you can unpack them:
        var (lat, lng) = london;
        Console.WriteLine($"Lat {lat}, Lng {lng}"); // Lat 51.5074, Lng -0.1278

        // They also work beautifully in pattern matching:
        string hemisphere = london switch
        {
            { Lat: >= 0 } => "Northern",
            _             => "Southern"
        };
        Console.WriteLine(hemisphere);               // Northern
    }
}

// ✅ Expected output:
//    Lat 51.5074, Lng -0.1278
//    Northern

🧩 Reorder Challenge

These lines define and use a record, but they're out of order. Arrange them so the program compiles and prints the discounted book.

A.         var sale = book with { Price = 9.99m };
B. record Book(string Title, decimal Price);
C.         Console.WriteLine(sale);
D.     static void Main()
E.         var book = new Book("Dune", 14.99m);
F. class Program {
G.     }
H. }
B. record Book(string Title, decimal Price);
F. class Program {
D.     static void Main()
E.         var book = new Book("Dune", 14.99m);
A.         var sale = book with { Price = 9.99m };
C.         Console.WriteLine(sale);
G.     }
H. }

// Output:  Book { Title = Dune, Price = 9.99 }

The record type must be declared before it's used, and you must create book before deriving sale from it. (Method braces around the body are simplified here.)

🧠 Quick Recall

1. What does this print?

record P(int X, int Y);
Console.WriteLine(new P(1, 2) == new P(1, 2));

True — records use value equality; both have X=1, Y=2.

2. What does this print?

record P(int X, int Y);
var a = new P(1, 2);
var b = a with { Y = 9 };
Console.WriteLine($"{a.Y} {b.Y}");

2 9 — with copies into a new record; a is unchanged.

3. What does this print?

record P(int X, int Y);
var (x, y) = new P(5, 7);
Console.WriteLine(x + y);

12 — the record deconstructs into x=5, y=7.

Putting It Together: Orders with LINQ

Records shine when modelling data that flows through your app. Here a list of order records is filtered and summed with LINQ, then one is "updated" immutably with with.

using System.Globalization;
using System;
using System.Collections.Generic;
using System.Linq;

// Records are perfect for data you pass around: DTOs, events, config.
record Order(int Id, string Customer, decimal Amount, string Status);

class Program
{
    static void Main()
    {
        // Currency formatting follows the thread's culture: {value:C} prints
        // £ in London, $ in Boston and € in Paris. Pin it when the output has
        // to be the same everywhere — as it does on a page that shows you the
        // result.
        CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-GB");

        var orders = new List<Order>
        {
            new(1, "Ana",  120m, "paid"),
            new(2, "Ben",   40m, "pending"),
            new(3, "Cara", 200m, "paid"),
        };

        // Value equality + immutability make records great with LINQ.
        decimal paidTotal = orders
            .Where(o => o.Status == "paid")
            .Sum(o => o.Amount);
        Console.WriteLine($"Paid total: {paidTotal:C}"); // £320.00

        // Mark order #2 as paid by producing a NEW record (immutable update):
        var updated = orders[1] with { Status = "paid" };
        Console.WriteLine(updated); // Order { Id = 2, Customer = Ben, Amount = 40, Status = paid }
    }
}

// ✅ Expected output:
//    Paid total: £320.00
//    Order { Id = 2, Customer = Ben, Amount = 40, Status = paid }

Pro Tips

Common Errors (and the fix)

📋 Quick Reference

TaskCode
Declarerecord Person(string Name, int Age);
Createnew Person("Al", 30)
Copy + changep with { Age = 31 }
Comparea == b
Deconstructvar (n, a) = p;
Value structrecord struct Pt(int X, int Y);

Mini-Challenge: Account Snapshot

Model a bank account as an immutable record and "deposit" by producing a new snapshot with with. Note how the two snapshots compare.

using System;

class Program
{
    static void Main()
    {
        // 🎯 MINI-CHALLENGE: Account snapshot
        // 1. Declare a record Account(string Owner, decimal Balance).
        // 2. Create acc = new Account("Sam", 100m).
        // 3. Use 'with' to make a deposit of 50:  afterDeposit = acc with { Balance = 150m }.
        // 4. Print both, and print whether acc == afterDeposit.
        //
        // ✅ Example output:
        //    Account { Owner = Sam, Balance = 100 }
        //    Account { Owner = Sam, Balance = 150 }
        //    Equal? False

        // your code here (declare the record ABOVE class Program)
    }
}

🎉 Lesson Complete

Practice quiz

How do two records with identical property values compare with ==?

  • They are never equal
  • It throws an exception
  • They are equal (value equality)
  • Only if they are the same object in memory

Answer: They are equal (value equality). Records compare by value: two records are equal when all their properties are equal.

What does a 'with' expression do?

  • Creates a copy with some properties changed
  • Mutates the original record in place
  • Deletes the record
  • Compares two records

Answer: Creates a copy with some properties changed. 'with' performs non-destructive mutation — it returns a new copy with the listed properties changed; the original is untouched.

What is the default mutability of positional record properties?

  • Fully mutable
  • Static
  • Private
  • init-only (read-only after construction)

Answer: init-only (read-only after construction). Positional record properties are init-only by default, which is what makes value equality and 'with' safe.

How many lines does 'record Person(string Name, int Age);' need to declare a constructor, properties, ToString and equality?

  • Ten
  • One
  • Five
  • Zero — you must write them

Answer: One. The positional syntax is the whole declaration; the compiler generates the rest.

Every positional record gets which method generated for unpacking?

  • Deconstruct
  • Dispose
  • Clone
  • GetHashSet

Answer: Deconstruct. Records auto-generate a Deconstruct method, so you can write var (a, b) = record;.

What does 'record struct Point(int X, int Y);' create?

  • A reference-type record
  • An interface
  • A value-type record copied by value
  • An abstract class

Answer: A value-type record copied by value. record struct gives a value-type record that is copied by value like a struct, while still getting generated equality and ToString.

For a normal class (not a record), what does a == b test by default?

  • Value equality
  • Reference equality (same object)
  • Property equality
  • Type equality

Answer: Reference equality (same object). Regular classes inherit reference equality — a == b is true only when both refer to the same object.

What does 'x with { ... };' do if you don't assign its result?

  • Mutates x
  • Throws CS8852
  • Deletes x
  • Nothing useful — the copy is discarded

Answer: Nothing useful — the copy is discarded. 'with' returns a copy; on its own, with no assignment, that copy is thrown away and x is unchanged.

When is a record generally preferred over a class?

  • When the object has identity and mutable behaviour
  • When it is primarily a bundle of data compared and copied by value
  • When you need a service or controller
  • Never — classes are always better

Answer: When it is primarily a bundle of data compared and copied by value. Use records for data (DTOs, events, coordinates); use classes for objects with identity and behaviour.

Why might error CS8852 ('Init-only property can only be assigned in an initializer') appear?

  • You used == on records
  • You forgot a constructor
  • You tried to mutate a record property after construction
  • You declared a record struct

Answer: You tried to mutate a record property after construction. Record properties are init-only; to 'change' one you use a with expression instead of mutating it.

Continue this course

Frequently asked questions

When should I use a record instead of a class?

Use a record when the object is primarily a bundle of data you compare and copy by value — DTOs, API responses, events, configuration, coordinates. Use a class when the object has identity and mutable behaviour, like a service, a controller, or an entity tracked by reference.

Are record properties really immutable?

Positional record properties are 'init-only' by default — you can set them when constructing the record but not afterwards. That's what makes value equality and 'with' expressions safe. You can opt into mutable properties, but immutability is the recommended default.

What does the 'with' expression actually do?

It creates a NEW record that's a copy of the original, with the listed properties changed. The original is never modified. This is called non-destructive mutation and is the idiomatic way to 'update' an immutable record.

What's a record struct?

Writing 'record struct Point(int X, int Y)' gives you a value-type record — it lives on the stack and is copied by value like a struct, while still getting the generated equality and ToString(). Use it for small, frequently-created data to reduce heap allocations.

Why does == compare values for records but references for classes?

The compiler generates an Equals override and == operator for records that compare every property. Regular classes inherit the default reference equality (same object in memory). That value-based equality is one of the headline reasons to use records.