Back to Blog
C#

C# Record Usage: Syntax, Equality, and Tradeoffs

c# record usage: Understand C# records: declaration syntax, value equality, with-expressions, inheritance, and when records fit better than classes or structs.

C#recordsvalue equalitywith-expressionsimmutable data
Illustration of two C# record instances compared by value, showing equality and immutable data.

c# record usage requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

What a Record Changes Compared to a Class

A record is a reference type by default, like a class, but the compiler generates additional members that change how instances behave. The most visible difference is value equality: two record instances are equal when their property values match, not when they reference the same object. The compiler also generates a protected copy constructor, an equality operator pair, and a Deconstruct method when you use positional syntax.

This matters for c# record usage because records remove a lot of boilerplate you would otherwise write by hand for data-carrying types. You get equality, hashing, and printing behavior from a single declaration.

Declaring Records With Positional Syntax

The positional form is the most common way to declare a record:

public record OrderItem(string ProductCode, int Quantity, decimal UnitPrice);

The compiler creates init-only properties for each parameter. That means you can set the values during construction, but you cannot reassign them afterward:

var item = new OrderItem("A-100", 2, 19.99m); // item.Quantity = 3; // compile error: init-only property

If you need mutable properties, you can declare them explicitly with set accessors, but that works against the immutable design that records are meant to provide.

How Value Equality Works

For a record, Equals compares each public property using the values, not the references. The compiler generates Equals, GetHashCode, and the == and != operators. Two separately constructed instances with the same values compare as equal:

var first = new OrderItem("A-100", 2, 19.99m); var second = new OrderItem("A-100", 2, 19.99m); Console.WriteLine(first == second); // True Console.WriteLine(first.Equals(second)); // True

The generated GetHashCode is based on the same property values, so records work correctly in hash-based collections like Dictionary and HashSet. If you override equality manually, you must keep Equals and GetHashCode consistent, which the compiler-generated version already does for you.

Using with-Expressions for Non-Destructive Mutation

Because record properties are init-only, you cannot mutate an instance in place. The with expression creates a copy with one or more properties changed:

var original = new OrderItem("A-100", 2, 19.99m); var updated = original with { Quantity = 5 }; Console.WriteLine(original.Quantity); // 2 Console.WriteLine(updated.Quantity); // 5

The original instance stays unchanged. The compiler uses the generated copy constructor to clone the record, then applies the property changes. This is the intended way to "update" a record, and it keeps the data flow explicit.

Records vs Classes vs Structs

The choice depends on what the type represents. A record is a good fit when the type is primarily a data carrier and you want value semantics without manual equality code. A class is better when the type has identity, mutable state, or behavior that depends on being the same instance. A struct is better for small, frequently allocated value types where avoiding heap allocation matters.

CriterionRecordClassStruct
EqualityValue-basedReference-basedValue-based
MutabilityInit-only by defaultFully mutableMutable if declared so
AllocationHeapHeapStack or inline
Typical useDTOs, messages, resultsServices, entities with identitySmall numeric-like values

A common mistake is using records for entity types that have identity, such as a customer whose equality should be based on an ID. If two customer objects with the same ID but different names should be equal, a record with value equality will not behave the way you want.

Record Inheritance and Hierarchies

Records support inheritance, and the equality behavior extends to derived types. A derived record inherits the equality members and adds its own properties to the comparison:

public record OrderItem(string ProductCode, int Quantity, decimal UnitPrice); public record DiscountedOrderItem( string ProductCode, int Quantity, decimal UnitPrice, decimal DiscountPercent) : OrderItem(ProductCode, Quantity, UnitPrice);

The positional declaration of the derived record passes the base parameters to the base constructor. Equality between a base and derived instance returns false when the types differ, which is the expected behavior for value equality.

Runtime and Performance Considerations

Records are reference types, so they allocate on the heap. The generated equality code iterates over properties, which is slower than a simple reference comparison. For large collections of records, repeated equality checks or hashing can become a measurable cost. If you are comparing millions of instances, a struct with manually implemented equality may be faster because it avoids the allocation and uses a more compact layout.

The with expression also allocates a new instance on every call. That is usually fine for occasional updates, but in a hot loop that clones records frequently, the allocation pressure can be significant.

Serialization and Framework Compatibility

Records serialize cleanly with System.Text.Json because they expose public properties with getters. Deserialization works with the init-only properties through the parameterized constructor that the positional syntax generates. Most modern frameworks handle records without extra configuration.

One limitation: records rely on generated members, so any code that reflects over the type and expects a parameterless constructor will not find one on a positional record. If a library requires a parameterless constructor, you may need to declare the record with explicit properties and a default constructor instead.

c# record usage: Practical Usage and Code Examples | RYUSLOG DEV