ZetCode

C# record

last modified October 18, 2026

In this article we show how to work with the record type in C#.

A record is a type whose main purpose is to hold data. A record declared with the record keyword (or record class) is a reference type; since C# 10, record struct declares a value type. The record type simplifies code, improves its readability, and removes unnecessary boilerplate.

The record type provides the following features:

The compiler creates an override of Object.Equals(Object) and Object.GetHashCode. It creates methods for the == and != operators and implements the System.IEquatable<T> interface. Records also provide an override of Object.ToString.

While records share a lot of similarities with standard classes, they have different purposes. Classes are used for defining objects with behaviour and responsibilities, records excel in storing data, for instance the results of queries, messages, or data transfer objects.

Immutable data types are an important part of functional programming. Records are known from functional languages such as F#; the record type appeared in C# 9.

C# record positional syntax

The easiest way to create a record is to use the positional syntax.

record User(string FirstName, string LastName, string Occupation);

Using this syntax, the compiler automatically creates the following:

The properties of a positional record class are init-only, so the data is immutable.

C# record simple examples

In the following example, we create a simple record type.

Program.cs
var users = new List<User>
{
    new ("John", "Doe", 1230),
    new ("Lucy", "Novak", 670),
    new ("Ben", "Walter", 2050),
    new ("Robin", "Brown", 2300),
    new ("Amy", "Doe", 1250),
    new ("Joe", "Draker", 1190),
    new ("Janet", "Doe", 980),
    new ("Albert", "Novak", 1930),
};

users.ForEach(Console.WriteLine);

Console.WriteLine(users[0].FirstName);
Console.WriteLine(users[0].LastName);
Console.WriteLine(users[0].Salary);

record User(string FirstName, string LastName, int Salary);

We create a User record in one line; we are ready to use it.

users.ForEach(Console.WriteLine);

We traverse the list of users and print them to the console. Thanks to the built-in formatting of records, we have a human-readable output for the records.

Console.WriteLine(users[0].FirstName);
Console.WriteLine(users[0].LastName);
Console.WriteLine(users[0].Salary);

The properties are automatically created.

$ dotnet run
User { FirstName = John, LastName = Doe, Salary = 1230 }
User { FirstName = Lucy, LastName = Novak, Salary = 670 }
User { FirstName = Ben, LastName = Walter, Salary = 2050 }
User { FirstName = Robin, LastName = Brown, Salary = 2300 }
User { FirstName = Amy, LastName = Doe, Salary = 1250 }
User { FirstName = Joe, LastName = Draker, Salary = 1190 }
User { FirstName = Janet, LastName = Doe, Salary = 980 }
User { FirstName = Albert, LastName = Novak, Salary = 1930 }
John
Doe
1230

In the second example, we use a record type with LINQ.

Program.cs
var cars = new List<Car>
{
    new ("Audi", "red", 52642),
    new ("Mercedes", "blue", 57127),
    new ("Skoda", "black", 9000),
    new ("Volvo", "red", 29000),
    new ("Bentley", "yellow", 350000),
    new ("Citroen", "white", 21000),
    new ("Hummer", "black", 41400),
    new ("Volkswagen", "white", 21600),
};

var groups = from car in cars
             group car by car.Colour;

foreach (var group in groups)
{
    Console.WriteLine(group.Key);

    foreach (var car in group)
    {
        Console.WriteLine($" {car.Name} {car.Price}");
    }
}

record Car(string Name, string Colour, int Price);

We use a LINQ query expression to group our cars by their colours. Again, we have defined the record type in one line.

$ dotnet run
red
 Audi 52642
 Volvo 29000
blue
 Mercedes 57127
black
 Skoda 9000
 Hummer 41400
yellow
 Bentley 350000
white
 Citroen 21000
 Volkswagen 21600

C# record equality

Records provide value-based equality. Classes on the other hand provide reference equality by default. To have value-based equality with classes, we have to add additional code to the class definitions, which is often generated by the IDE and is considered boilerplate.

Program.cs
var u1 = new User("John", "Doe", "gardener");
var u2 = new User("John", "Doe", "gardener");

Console.WriteLine(u1 == u2);

var p1 = new Person("Roger", "Roe", "driver");
var p2 = new Person("Roger", "Roe", "driver");

Console.WriteLine(p1 == p2);

record User(string FirstName, string LastName, string Occupation);

class Person
{
    public Person(string firstName, string lastName, string occupation)
    {
        FirstName = firstName;
        LastName = lastName;
        Occupation = occupation;
    }

    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Occupation { get; set; }
}

In the example, we compare two records and two class objects having the same data.

$ dotnet run
True
False

The records compare values while the class objects compare references. The second output is False because p1 and p2 point to two different objects in memory.

C# record equality and collections

Value-based equality compares the properties with their own Equals methods. Arrays and lists use reference equality, so two records with equal collections are not equal.

Program.cs
var o1 = new Order(1, ["pen", "notebook"]);
var o2 = new Order(1, ["pen", "notebook"]);

Console.WriteLine(o1 == o2);
Console.WriteLine(o1);

var t1 = new Tags("C#", ".NET");
var t2 = new Tags("C#", ".NET");

Console.WriteLine(t1 == t2);

record Order(int Id, string[] Items);

record Tags(string First, string Second);

The two orders contain two different arrays with the same items.

$ dotnet run
False
Order { Id = 1, Items = System.String[] }
True

The orders are not equal, and the generated ToString prints only the type name of the array. The Tags records, which contain only strings, are equal. If we need value equality for collections, we must override the Equals and GetHashCode methods, or compare the collections with SequenceEqual.

C# record Deconstruct

With the positional syntax, we have the Deconstruct method automatically implemented.

Program.cs
var u = new User("John", "Doe", 980);

(string fname, string lname, int sal) = u;

Console.WriteLine($"{fname} {lname} earns {sal} per month");

record User(string FirstName, string LastName, int Salary);

A record's properties can be easily separated into variables with deconstruction operation.

$ dotnet run
John Doe earns 980 per month

C# record non-destructive mutation

In functional programming, we work with immutable data. When we need to modify data, we create modified copies of the original data, which stays intact. Immutable data can be safely shared between threads.

We can use the with expression to get modified copies of our records.

Program.cs
var users = new List<User>
{
    new ("John", "Doe", 1230),
    new ("Lucy", "Novak", 670),
    new ("Ben", "Walter", 2050),
    new ("Robin", "Brown", 2300),
    new ("Amy", "Doe", 1250),
    new ("Joe", "Draker", 1190),
    new ("Janet", "Doe", 980),
    new ("Albert", "Novak", 1930),
};

var users2 = new List<User>();
users.ForEach(u => users2.Add(u with { Salary = u.Salary + 200 }));

users.ForEach(Console.WriteLine);

Console.WriteLine("---------------");

users2.ForEach(Console.WriteLine);

record User(string FirstName, string LastName, int Salary);

In the example, we have a list of users. We want to add a bonus to each user. Rather than modifying the original list, we create a new one with their salaries modified.

$ dotnet run
User { FirstName = John, LastName = Doe, Salary = 1230 }
User { FirstName = Lucy, LastName = Novak, Salary = 670 }
User { FirstName = Ben, LastName = Walter, Salary = 2050 }
User { FirstName = Robin, LastName = Brown, Salary = 2300 }
User { FirstName = Amy, LastName = Doe, Salary = 1250 }
User { FirstName = Joe, LastName = Draker, Salary = 1190 }
User { FirstName = Janet, LastName = Doe, Salary = 980 }
User { FirstName = Albert, LastName = Novak, Salary = 1930 }
---------------
User { FirstName = John, LastName = Doe, Salary = 1430 }
User { FirstName = Lucy, LastName = Novak, Salary = 870 }
User { FirstName = Ben, LastName = Walter, Salary = 2250 }
User { FirstName = Robin, LastName = Brown, Salary = 2500 }
User { FirstName = Amy, LastName = Doe, Salary = 1450 }
User { FirstName = Joe, LastName = Draker, Salary = 1390 }
User { FirstName = Janet, LastName = Doe, Salary = 1180 }
User { FirstName = Albert, LastName = Novak, Salary = 2130 }

C# mutable record

It is possible to create a mutable record by declaring properties with set accessors. However, when possible, immutable records are preferred; a mutable record whose hash code changes can get lost in a hash set or a dictionary.

Program.cs
var u = new User { FirstName = "John", LastName = "Doe", Occupation = "gardener" };
Console.WriteLine(u);

u.Occupation = "driver";
Console.WriteLine(u);

record User
{
    public required string FirstName { get; set; }
    public required string LastName { get; set; }
    public required string Occupation { get; set; }
}

The record is declared with standard properties instead of the positional syntax. The required modifier makes sure that all properties are set in the object initializer.

$ dotnet run
User { FirstName = John, LastName = Doe, Occupation = gardener }
User { FirstName = John, LastName = Doe, Occupation = driver }

C# record struct

Since C# 10, records can be value types. A record struct has the same features as a record class: value equality, formatting, deconstruction, and the with expression. Its positional properties are mutable; a readonly record struct makes them init-only.

Program.cs
var p1 = new Point(2, 3);
var p2 = p1 with { Y = 10 };

Console.WriteLine(p1);
Console.WriteLine(p2);
Console.WriteLine(p1 == new Point(2, 3));

var c = new Color(255, 128, 0);
c.R = 0;
Console.WriteLine(c);

// p1.X = 5; error CS8852: init-only property

readonly record struct Point(int X, int Y);

record struct Color(byte R, byte G, byte B);

We have a read-only Point and a mutable Color.

var c = new Color(255, 128, 0);
c.R = 0;

The properties of a positional record struct can be changed.

// p1.X = 5; error CS8852: init-only property

Assigning to a property of the read-only Point would be a compile-time error.

$ dotnet run
Point { X = 2, Y = 3 }
Point { X = 2, Y = 10 }
True
Color { R = 0, G = 128, B = 0 }

Record structs are suitable for small values such as points, colours, or money amounts, where avoiding heap allocations matters.

C# record inheritance

A record class can inherit from another record class, but not from an ordinary class.

Program.cs
Person p = new Employee("John Doe", "Acme");
Console.WriteLine(p);

var e1 = new Employee("John Doe", "Acme");
var e2 = e1 with { Company = "Globex" };
Console.WriteLine(e2);

Console.WriteLine(new Person("John Doe") == new Employee("John Doe", "Acme"));

record Person(string Name);
record Employee(string Name, string Company) : Person(Name);

The Employee record derives from the Person record and passes the Name to its primary constructor.

$ dotnet run
Employee { Name = John Doe, Company = Acme }
Employee { Name = John Doe, Company = Globex }
False

The ToString method and the with expression work with the runtime type, so we get an Employee even through a Person variable. Equality also takes the type into account; a Person and an Employee with the same name are not equal.

Web scraping example

To demonstrate the usefulness of records, we have a more complex example which scrapes data from a website and saves it to a CSV file.

$ dotnet add package AngleSharp
$ dotnet add package CsvHelper

We need to add the AngleSharp and CsvHelper packages to the project.

Program.cs
using System.Globalization;
using AngleSharp;
using CsvHelper;

var config = Configuration.Default.WithDefaultLoader();
using var context = BrowsingContext.New(config);

using var doc = await context.OpenAsync("https://quotes.toscrape.com");

var quotes = doc.QuerySelectorAll("div.quote")
    .Select(q => new Quote(
        q.QuerySelector("small.author")?.TextContent ?? "",
        q.QuerySelector("span.text")?.TextContent.Trim('“', '”') ?? "",
        q.QuerySelectorAll("a.tag").Length))
    .ToList();

using (var writer = new StreamWriter("quotes.csv"))
using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture))
{
    csv.WriteRecords(quotes);
}

Console.WriteLine($"{quotes.Count} quotes written to quotes.csv");

var byAuthor = quotes
    .GroupBy(q => q.Author)
    .OrderByDescending(g => g.Count())
    .Take(3);

foreach (var g in byAuthor)
{
    Console.WriteLine($"{g.Key}: {g.Count()}");
}

record Quote(string Author, string Text, int Tags);

We download the first page of the quotes.toscrape.com website, which is designed for practising web scraping. For each quote, we create a Quote record with the author, the text, and the number of tags.

csv.WriteRecords(quotes);

CsvHelper writes the records to the CSV file. It uses the names of the record properties for the header row.

var byAuthor = quotes
    .GroupBy(q => q.Author)
    .OrderByDescending(g => g.Count())
    .Take(3);

We also analyse the data with LINQ: we find the authors with the most quotes on the page.

$ dotnet run
10 quotes written to quotes.csv
Albert Einstein: 3
J.K. Rowling: 1
Jane Austen: 1
$ head -3 quotes.csv
Author,Text,Tags
Albert Einstein,The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.,4
J.K. Rowling,"It is our choices, Harry, that show what we truly are, far more than our abilities.",2

Source

Records - language reference

In this article we have covered the C# record type.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all C# tutorials.