C# & .NET (C# 14, .NET 10 LTS) Module 8 – Structs, records, tuples and value semantics
Class, struct, record or tuple: how to choose
Choose between a C# class, struct, record and tuple with five questions about your data, then check the choice against measured costs and real bugs.
What you will learn
- Apply five questions about a type's data to choose a class, a struct, a record or a tuple
- Predict what ==, Equals and a HashSet do with the same value in each kind of type
- Measure the allocation and copy costs of a choice instead of assuming them
- Refactor a model whose kinds of type were chosen by habit, type by type
Before you start
What you’ll learn · Before you start
What you will learn
- Apply five questions about a type's data to choose a class, a struct, a record or a tuple
- Predict what ==, Equals and a HashSet do with the same value in each kind of type
- Measure the allocation and copy costs of a choice instead of assuming them
- Refactor a model whose kinds of type were chosen by habit, type by type
Before you start
On this page
A Sku class used as a dictionary key kept two entries for one product, and a StockLevel struct in a dictionary lost
15 units it had received. Neither bug was in the logic; both were in the kind of type. C# gives four kinds for a group
of data, a class, a struct, a record and a tuple, and a few questions about the data decide between them.
You will choose a kind with five questions, see what each kind does with the same value, and check a choice against measured costs and a refactored model.
Five questions decide the kind of a type
A class is a reference type that compares references, so its objects keep an identity while their data
changes. A record writes value equality for you: a record class is still a reference, a readonly record struct
a small value. A struct is copied whole at every assignment and suits one small value that never changes. A
tuple groups a few values with no declaration, and its names exist only for the compiler. Answer the questions for
a type of your own:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Kind | Keeps an identity | Equal by value | Cost per value | Crosses an API | Inheritance |
|---|---|---|---|---|---|
| class | Good one object, shared by every variable that refers to it | Poor compares references unless you write Equals: a set kept 2 of 2 equal values | Mixed an object on the heap: 1,000 values took 40,024 bytes | Good real properties: JSON and logs see the names | Good derives from any class that is not a record or sealed, and classes derive from it |
| record class | Poor two records with equal values count as one thing | Good generated equality: a set kept 1 of 2 equal values | Mixed an object on the heap, like a class: 40,024 bytes for 1,000 | Good init properties with real names: JSON and logs see them | Mixed derives from and is derived from records only |
| readonly record struct | Poor every assignment makes an equal, separate copy | Good generated equality with no boxing: a set kept 1 of 2 | Good stored inline: 1,000 values in an array took 16,024 bytes | Good real properties, like a record class | Poor derives from no class or struct, and nothing derives from it |
| struct that can change | Poor a list, a property or foreach hands out copies, and changes go into them | Mixed the default Equals boxes, and has no == at all | Good 16,024 bytes for 1,000 in an array, as small as the record struct | Mixed serializes, but every caller can change a copy by mistake | Poor derives from no class or struct, and nothing derives from it |
| value tuple | Poor a struct: copied at every assignment | Good compares elements in order, never their names | Good 16,024 bytes for 1,000 in an array | Poor names exist only for the compiler: System.Text.Json writes {} | Poor derives from no class or struct, and nothing derives from it |
How the answers decide
- “Is it the same thing while its data changes, such as a customer, an order or a warehouse?” Yes, it has an identity: rules out record class, readonly record struct, struct that can change and value tuple. Something with an identity needs one object that changes. Value equality and copies would make two of it, or merge two into one.
- “Is it the same thing while its data changes, such as a customer, an order or a warehouse?” No, it is just its values: adds a caveat to class. A class compares references, so a value type of data needs its own Equals and GetHashCode, which a record writes for you.
- “Do its values change after it is made?” Yes, they change: rules out readonly record struct and adds a caveat to record class, struct that can change and value tuple. A readonly record struct cannot change. A record with setters is lost in a set when it changes, and a struct or tuple changes only the copy you hold.
- “Do its values change after it is made?” No, a change makes a new value: adds a caveat to struct that can change. Values that never change are what readonly struct promises; a struct that can change only invites lost updates.
- “Is it small, a few fields of about 16 bytes, and made in large numbers?” Yes, small and many: adds a caveat to class and record class. Every value would be an object on the heap: 40 bytes each in the run, against 16 stored inline for a struct.
- “Is it small, a few fields of about 16 bytes, and made in large numbers?” No, larger or rare: adds a caveat to readonly record struct, struct that can change and value tuple. A struct is copied whole at every assignment, argument and return; above about 16 bytes that cost grows with every copy.
- “Does it cross an API, or get stored, serialized or logged?” Yes: rules out value tuple. A tuple's names vanish outside the compiler, so JSON, logs and other languages see Item1 and Item2, or nothing.
- “Does it need a base type or derived types?” Yes: rules out readonly record struct, struct that can change and value tuple and adds a caveat to record class. Structs and tuples never derive from a class or another struct, and a record derives only from records.
The first question settles most cases. A customer, an order or a warehouse is an entity: A type whose objects have an identity that stays while their data changes, such as a customer, an order or a warehouse. Two entities with equal data are still two; in C# an entity is usually a class.: it stays the same thing while its data changes, which only a class models. An amount, a coordinate or a product code is a value object: A type defined by its values alone, such as an amount of money or a coordinate: two with equal values can stand for each other. In C# it is often a record, or a readonly record struct when it is small.: two with equal values can stand for each other, which is what records say. The other questions choose between a record class and a record struct by size, and rule the tuple out once the values leave one class.
Checkpoint
Check: Identity, values and the five questions
The same value in five kinds of type, measured
kinds.cs declares one amount of money, 25,000 paise in INR, in five kinds of type, then asks each about equality and
counts the heap bytes of 1,000 values in an array:
// kinds.cs: one amount of money in five kinds of type: what == and Equals say, what a set keeps,
// and the heap bytes that 1,000 such values in an array take.
using System.Runtime.CompilerServices;
Console.WriteLine("kind a == b a.Equals(b) set keeps bytes for 1,000 in an array");
Row("class", $"{new MoneyClass(25_000, "INR") == new MoneyClass(25_000, "INR")}", new MoneyClass(25_000, "INR").Equals(new MoneyClass(25_000, "INR")),
new HashSet<MoneyClass> { new(25_000, "INR"), new(25_000, "INR") }.Count, ClassBytes());
Row("record class", $"{new MoneyRecord(25_000, "INR") == new MoneyRecord(25_000, "INR")}", new MoneyRecord(25_000, "INR").Equals(new MoneyRecord(25_000, "INR")),
new HashSet<MoneyRecord> { new(25_000, "INR"), new(25_000, "INR") }.Count, RecordBytes());
Row("struct", "none", new MoneyStruct(25_000, "INR").Equals(new MoneyStruct(25_000, "INR")),
new HashSet<MoneyStruct> { new(25_000, "INR"), new(25_000, "INR") }.Count, StructBytes());
Row("readonly record struct", $"{new MoneyValue(25_000, "INR") == new MoneyValue(25_000, "INR")}", new MoneyValue(25_000, "INR").Equals(new MoneyValue(25_000, "INR")),
new HashSet<MoneyValue> { new(25_000, "INR"), new(25_000, "INR") }.Count, ValueBytes());
Row("tuple", $"{(25_000L, "INR") == (25_000L, "INR")}", (25_000L, "INR").Equals((25_000L, "INR")),
new HashSet<(long, string)> { (25_000, "INR"), (25_000, "INR") }.Count, TupleBytes());
static void Row(string kind, string equalsOperator, bool equalsMethod, int setCount, long bytes) =>
Console.WriteLine($"{kind,-24}{equalsOperator,-8}{equalsMethod,-13}{setCount,-11}{bytes,6}");
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
static long ClassBytes()
{
long before = GC.GetAllocatedBytesForCurrentThread();
var items = new MoneyClass[1000];
for (int i = 0; i < items.Length; i++) items[i] = new MoneyClass(i, "INR");
return GC.GetAllocatedBytesForCurrentThread() - before;
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
static long RecordBytes()
{
long before = GC.GetAllocatedBytesForCurrentThread();
var items = new MoneyRecord[1000];
for (int i = 0; i < items.Length; i++) items[i] = new MoneyRecord(i, "INR");
return GC.GetAllocatedBytesForCurrentThread() - before;
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
static long StructBytes()
{
long before = GC.GetAllocatedBytesForCurrentThread();
var items = new MoneyStruct[1000];
for (int i = 0; i < items.Length; i++) items[i] = new MoneyStruct(i, "INR");
return GC.GetAllocatedBytesForCurrentThread() - before;
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
static long ValueBytes()
{
long before = GC.GetAllocatedBytesForCurrentThread();
var items = new MoneyValue[1000];
for (int i = 0; i < items.Length; i++) items[i] = new MoneyValue(i, "INR");
return GC.GetAllocatedBytesForCurrentThread() - before;
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
static long TupleBytes()
{
long before = GC.GetAllocatedBytesForCurrentThread();
var items = new (long Paise, string Currency)[1000];
for (int i = 0; i < items.Length; i++) items[i] = (i, "INR");
return GC.GetAllocatedBytesForCurrentThread() - before;
}
class MoneyClass(long paise, string currency)
{
public long Paise { get; } = paise;
public string Currency { get; } = currency;
}
record MoneyRecord(long Paise, string Currency);
struct MoneyStruct(long paise, string currency)
{
public long Paise { get; } = paise;
public string Currency { get; } = currency;
}
readonly record struct MoneyValue(long Paise, string Currency); Output
kind a == b a.Equals(b) set keeps bytes for 1,000 in an array class False False 2 40024 record class True True 1 40024 struct none True 1 16024 readonly record struct True True 1 16024 tuple True True 1 16024
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run kinds.cs
The class was the only kind that called two equal amounts different, and its set kept both. The plain struct had no
== at all, hence none in its row, though its default Equals found the two equal. The record class, the record
struct and the tuple compared by value.
The bytes split the kinds in two. The class and the record class took 40,024 bytes: 24 for the array’s header, 8,000 for its references and 32 for each object. The three structs took 16,024, because their values sit inside the array, 16 bytes each. A record class is still a class, so its equality changed and its cost did not.
Checkpoint
Check: One value in five kinds, compared and counted
Sensible defaults for the roles types play
Most types play one of a few roles, and each role has a kind that fits it unless measurements say otherwise:
| Role | Example | Default kind | Why |
|---|---|---|---|
| entity | Warehouse, Order, Customer |
class | one object per thing, changing over time |
| service | PricingService |
class | behaviour and dependencies, no value of its own |
| message or DTO | Movement, FareQuote |
record class | immutable data, compared and printed by value |
| small value | Money, GeoPoint, Sku |
readonly record struct | a few fields, many copies, value equality |
| private result | MinMax, TryParse helpers |
value tuple | two or three values from one method |
| query row | grouping inside one method | anonymous type or tuple | never leaves the method |
Microsoft’s tutorial on choosing types gives much the same advice. Two rules settle the edge cases: a value over about 16 bytes is a record class, because a struct is copied whole, and a type that a serializer or an engine handles follows that tool first. Unity’s manual says its serializer does not support records, so Unity components keep plain fields.
Key idea
Ask first whether the thing has an identity: entities are classes, values are records, and size then decides between a record class and a readonly record struct. Tuples stay inside one class.
Checkpoint
Check: Default kinds for common roles
Measured: a million values, a million comparisons and the cost of a copy Pro: opens with a Pro, Premium or Ultimate pass
Defaults are a start; a hot path needs numbers. The runs below scale kinds.cs up to a million values, count what a
struct without equality members costs a HashSet, and time a 64-byte struct passed in three ways.
In this part:
- One million values, one million comparisons
- What a copy costs: a 64-byte struct by value, in and ref
- Working out the bytes of your own types
2 runnable examples (C#) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Refactoring a warehouse model, type by type Pro: opens with a Pro, Premium or Ultimate pass
The bugs of the opening came from a small warehouse model. This part runs the model as it was written, names the kind of type behind each failure, and runs it again with each type given the kind its job needs.
In this part:
- Four bugs from kinds chosen by habit
- Each type given the kind its job needs
- Changing a type’s kind without breaking its callers
- A checklist for reading code that chooses kinds
2 runnable examples (C#) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: predict each kind, then change one
Turn the struct into a record struct
Copy kinds.cs. Replace the declaration of MoneyStruct with
readonly record struct MoneyStruct(long Paise, string Currency);, and in its Row call put the == expression of
the record rows where "none" stands. Predict the row first: what a == b prints, and whether the bytes change.
Then run dotnet run kinds.cs and explain the row in one sentence.
Then check what you would choose:
Practice that adapts to you
Practise this lesson
10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · When should you choose a struct over a class in C#?
Choose a struct for a small value that stands for one thing and never changes, such as an amount, a coordinate or a
code, above all when a program makes many of them. A struct is copied whole at every assignment and call, so it should
stay about 16 bytes or less, and be a readonly struct or a readonly record struct so that copies cannot lose
changes. It saves an allocation per value: 1,000 amounts took 16,024 bytes as structs, 40,024 as classes. Choose a
class for anything with an identity, anything that changes over its life, and anything large or needing inheritance.
-
Mid to senior
You are designing Money for a payments service: an amount in paise and an ISO currency code, held by the million in memory and used as dictionary keys. Which kind of type do you choose, what goes into it, and what do you check before you commit to the choice?
-
Mid to senior
A colleague turned every DTO of a service into a mutable struct for performance. Requests now take longer, and a bug report shows an order status update that never sticks. How do you respond, and how do you decide with evidence which types should stay structs?
-
Fresher to mid
Reading a teammate's change, you find class Sku used as a dictionary key with no equality members, a mutable struct StockLevel stored in a Dictionary, and a record Warehouse that holds the stock dictionary. What do you say about each, and in which order would you fix them?
Key takeaways
- An entity, which keeps an identity while its data changes, is a class; a value is a record, and its size decides between a record class and a readonly record struct.
- For the same amount, the class compared references and took 40 bytes a value, the structs took 16, and the record class gave value equality at a class’s cost.
- Defaults follow roles: classes for entities and services, record classes for messages and DTOs, readonly record structs for small values, tuples for private results.
Terms in this lesson
- entity
- A type whose objects have an identity that stays while their data changes, such as a customer, an order or a warehouse. Two entities with equal data are still two; in C# an entity is usually a class.
- value object
- A type defined by its values alone, such as an amount of money or a coordinate: two with equal values can stand for each other. In C# it is often a record, or a readonly record struct when it is small.
References
- Tutorial: Choose between tuples, records, structs, and classes (Microsoft Learn)
- Choosing between class and struct (Framework Design Guidelines) (Microsoft Learn)
- Records (C# reference) (Microsoft Learn)
- Tuple types (C# reference) (Microsoft Learn)
- Avoid memory allocations and data copies (Microsoft Learn)
- ValueType.GetHashCode method (Microsoft Learn)
- FrozenDictionary class (Microsoft Learn)
- BenchmarkDotNet overview (.NET Foundation (BenchmarkDotNet))
- C# compiler and language version reference (records and Unity serialization) (Unity Technologies)
Report a problem with this lesson
Kept only in this browser. Your Learn progress