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:
- concise syntax for data-centric objects
- concise syntax for immutable data
- value-based equality
- concise syntax for non-destructive mutation
- built-in formatting for printing
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:
- a public init-only auto-implemented property for each positional parameter provided in the record declaration
- a primary constructor whose parameters match the positional parameters on the record declaration
- a
Deconstructmethod with an out parameter for each positional parameter provided in the record declaration
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
In this article we have covered the C# record type.
Author
List all C# tutorials.