Menu

C# Record: Value Equality, with Expressions and record struct

What C# records are (C# 9 and later): positional syntax, the members the compiler generates, value-based equality, non-destructive copies with with, the built-in ToString, record struct from C# 10, inheritance between records, and the equivalent class written by hand.

This page includes runnable editors - edit, run, and see output instantly.

A record is a type whose main job is to hold data, and whose equality is defined by that data. Records arrived in C# 9. You write one line, and the compiler generates the members a data class needs: properties, a constructor, value-based Equals and ==, GetHashCode, a readable ToString, Deconstruct, and support for with copies.

Records need C# 9 or later (.NET 5+), so the record code on this page is shown as plain C#, with its output in comments. The last section writes the same members by hand in C# 7, which you can run.

Positional records

The shortest form lists the properties in parentheses after the name. Each parameter becomes a public init-only property with the same name:

public record Product(string Sku, string Name, decimal Price);

var mug = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(mug.Name);        // Mug
Console.WriteLine(mug);             // Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
// mug.Price = 4m;                  // error CS8852: init-only property

var (sku, name, price) = mug;       // generated Deconstruct
Console.WriteLine($"{sku} {price}"); // MUG-01 8.50

From that one line, the compiler generates:

  • a constructor taking (string Sku, string Name, decimal Price);
  • three public ... { get; init; } properties;
  • Equals(object), Equals(Product), GetHashCode(), and the == and != operators, all comparing the three properties;
  • ToString() printing the type name and every public property;
  • Deconstruct(out string Sku, out string Name, out decimal Price);
  • a copy constructor (protected, or private in a sealed record) and a hidden clone method used by with.

A record can also be written with a normal body, which is handy when properties need defaults or validation:

public record Customer
{
    public required string Email { get; init; }   // required: C# 11
    public string Name { get; init; } = "";
}

var c = new Customer { Email = "ana@example.com" };

And both forms can be combined: positional parameters plus extra members in braces.

public record Order(string Id, decimal Subtotal)
{
    public decimal Tax => Subtotal * 0.23m;
    public decimal Total => Subtotal + Tax;
}

Value equality

For a normal class, == asks "are these the same object?". For a record, it asks "do these have the same values?":

var a = new Product("MUG-01", "Mug", 8.50m);
var b = new Product("MUG-01", "Mug", 8.50m);

Console.WriteLine(a == b);                  // True
Console.WriteLine(a.Equals(b));             // True
Console.WriteLine(ReferenceEquals(a, b));   // False: still two objects

GetHashCode is generated to match, so records work correctly as dictionary keys and in a HashSet<T>: a second record with the same values finds the first one's entry.

Equality compares each field (for positional records, the field behind each property) with EqualityComparer<T>.Default, which calls the type's own Equals. For a collection property, that is reference equality, which surprises people:

public record Basket(string Owner, List<string> Items);

var x = new Basket("Ana", new List<string> { "tea" });
var y = new Basket("Ana", new List<string> { "tea" });
Console.WriteLine(x == y);   // False: two different List objects

If a record holds a collection and should compare by its contents, override Equals(Basket other) and GetHashCode(), or use an immutable collection with value semantics of your own.

with expressions: non-destructive changes

Records are usually immutable, so you "change" one by creating a modified copy. with copies every property, then applies the assignments in braces:

var mug = new Product("MUG-01", "Mug", 8.50m);
var sale = mug with { Price = 6.00m };

Console.WriteLine(sale);        // Product { Sku = MUG-01, Name = Mug, Price = 6.00 }
Console.WriteLine(mug.Price);   // 8.50: the original is untouched

The copy is shallow. A reference-type property is copied as a reference, so both records share the object:

public record Customer { public List<string> Tags { get; init; } = new(); /* ... */ }

var c1 = new Customer { Email = "ana@example.com", Tags = { "vip" } };
var c2 = c1 with { Name = "Ana" };
c2.Tags.Add("newsletter");

Console.WriteLine(string.Join(",", c1.Tags));   // vip,newsletter

Either keep record properties immutable all the way down (IReadOnlyList<T> filled once, or ImmutableList<T>), or create a new list in the with: c1 with { Tags = new List<string>(c1.Tags) }.

ToString

The generated ToString prints the type name and every public property, which makes records pleasant in logs and the debugger:

Console.WriteLine(new Product("MUG-01", "Mug", 8.50m));
// Product { Sku = MUG-01, Name = Mug, Price = 8.50 }

Collections print as their type name (System.Collections.Generic.List`1[System.String]), and nested records print recursively. You can replace the whole output by overriding ToString:

public record Money(decimal Amount, string Currency)
{
    public override string ToString() => $"{Amount:F2} {Currency}";
}

record struct (C# 10)

record on its own means record class: a reference type. C# 10 added record struct, a value type with the same generated members:

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

var p = new Point(3, 4);
var q = p with { Y = 10 };
Console.WriteLine(p == new Point(3, 4));   // True
Console.WriteLine(q);                      // Point { X = 3, Y = 10 }

The difference in defaults is worth remembering: a positional record struct has mutable properties ({ get; set; }), matching how structs usually behave, while readonly record struct and record class have init-only ones. Choose between them the way you would choose between a struct and a class: small values that are copied freely suit readonly record struct; everything else, record.

Inheritance

A record can inherit from another record (not from a class, and a class cannot inherit from a record). Positional parameters are passed to the base like constructor arguments:

public abstract record Shape(string Color);
public record Circle(string Color, double Radius) : Shape(Color);
public record Square(string Color, double Side) : Shape(Color);

Shape a = new Circle("red", 2);
Shape b = new Circle("red", 2);
Shape c = new Square("red", 2);

Console.WriteLine(a == b);   // True
Console.WriteLine(a == c);   // False: different runtime types are never equal
Console.WriteLine(a);        // Circle { Color = red, Radius = 2 }

Equality includes the runtime type, through a generated EqualityContract property. That is why a Circle never equals a Square with the same Color, even though both are compared through Shape, and why ToString and with work on the derived type even when the variable is typed as the base.

The same thing in C# 7: a class with value equality

Records generate code you can write yourself, and seeing it explains their behavior. Here is a C# 7 class equivalent to public record Point(int X, int Y);: get-only properties, a constructor, Deconstruct, value equality, a matching hash code, ==, ToString, and a With method standing in for the with expression.

Output:

True
False
Point { X = 3, Y = 10 }
Point { X = 3, Y = 4 }
x=3, y=10
True
False

About 30 lines for two properties, and every new property means touching the constructor, Deconstruct, Equals, GetHashCode and ToString again. Forgetting one of them is a classic bug (two points that are == but hash differently, so a HashSet loses track of them). That maintenance is what records remove.

The class is sealed on purpose: value equality combined with inheritance needs the extra type check that records generate through EqualityContract, and sealing sidesteps the problem.

When to use a record

Records fit data that is defined by its values and does not change after creation:

  • request and response models for web APIs;
  • messages, commands and events passed between parts of a system;
  • configuration and options objects;
  • composite dictionary keys (record CacheKey(string Region, int Year));
  • results of a calculation (record PriceQuote(decimal Net, decimal Tax)).

They fit poorly where identity matters more than values: an Entity Framework entity is "customer 42" even after its name changes, and EF Core's change tracking relies on reference identity. Use a class there.

Common mistakes

  • Expecting deep equality for collections. A List<T> property compares by reference. Two records with equal-looking lists are not equal.
  • Expecting with to deep copy. Nested objects and collections are shared between the original and the copy.
  • Mutable positional record struct by accident. Add readonly unless you want settable properties.
  • Using records as EF Core entities. Value equality and copying conflict with change tracking.
  • Adding a record to a C# 8 project. Records need C# 9 (the default for .NET 5 and later). On older targets, write the class by hand as shown above.

Frequently Asked Questions

What is a record in C#?

A record (C# 9) is a class, or with record struct (C# 10) a struct, for which the compiler generates value-based equality, a readable ToString(), a Deconstruct method and support for with copies. public record Product(string Sku, decimal Price); is a complete type with two init-only properties. Two records with equal property values are equal, even though they are different objects.

What is the difference between a record and a class in C#?

A record is a class underneath, so it is a reference type and can inherit from other records. The differences are generated members: records compare by value (== and Equals check every field), print their properties from ToString(), and support with. A normal class compares by reference and prints its type name unless you write those members yourself.

What does the with expression do in C#?

var sale = product with { Price = 6.00m }; creates a new record that copies every property of product and then sets the ones listed. The original is unchanged. The copy is shallow: a List<T> property is shared by both records, so adding to it through one is visible through the other.

What is a record struct in C#?

record struct (C# 10) is a value type with the same generated members as a record class: value equality, ToString, Deconstruct and with. Unlike a record class, its positional properties are mutable by default; declare it readonly record struct to make them init-only. Use it for small values such as coordinates or money amounts.

When should I use a record in C#?

For data whose identity is its values: DTOs, API request and response models, messages and events, configuration, and keys for dictionaries. Avoid records for entities that change over time and are identified by an id, such as Entity Framework entities, because value equality and with copies work against change tracking.

Coddy programming languages illustration

Learn to code with Coddy

GET STARTED