Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 6 of 15 minutes free
Free: Five questions decide the kind of a type · The same value in five kinds of type, measured · Sensible defaults for the roles types play. With a pass: 2 in-depth parts and 3 interview answers, about 9 minutes.

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:

Interactive · answer and compare Which kind of type fits your data? Answer the five questions about your type. Each answer rules kinds out or adds a caveat, and the columns that decide it are marked.
Is it the same thing while its data changes, such as a customer, an order or a warehouse?
Do its values change after it is made?
Is it small, a few fields of about 16 bytes, and made in large numbers?
Does it cross an API, or get stored, serialized or logged?
Does it need a base type or derived types?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five kinds of type against identity, equality, cost per value, crossing an API and inheritance
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

  1. Question 1 of 2 A Warehouse has a code, and its stock changes all day; two parts of a program must see the same stock. Which kind fits?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A class: it is an entity, one object that changes

    The first question, identity or value, settles most choices: entities are classes.

  2. Question 2 of 2 A Sku is a product code such as RICE-5KG, never changes, is made by the million and is used as a dictionary key. Which kind fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: readonly record struct Sku(string Code)

    Values are records; small values that are made in large numbers are readonly record structs.

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 C# · kinds/kinds.cs
// 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

  1. Question 1 of 2 kinds.cs put two equal amounts into a HashSet for each kind. How many items did the set of the class keep?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 2 items

    A class without equality members compares references, so two equal amounts are two different items; every other kind kept 1.

  2. Question 2 of 2 Why did 1,000 amounts take 40,024 bytes as a record class but 16,024 as a readonly record struct?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A record class is a class: an 8-byte reference in the array plus a 32-byte object; a struct sits in the array, 16 bytes

    The record modifier changes equality, not storage: a record class costs what a class costs.

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

  1. Question 1 of 2 Which default kind fits a FareQuote returned by an API and compared in tests?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A record class

    Messages and DTOs are record classes by default.

  2. Question 2 of 2 A Reading value holds eight double fields, 64 bytes, never changes and is compared by value. Which kind is the default?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A record class: it is a value, but too large to copy whole at every call

    A large value is a record class unless measurements on a hot path say a struct passed with in pays off.

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.

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.

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.

  1. Question 1 of 10 Which question settles most choices between the kinds of type?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: Is it the same thing while its data changes, or is it just its values?

    Identity first, then change, size, edges and inheritance.

  2. Question 2 of 10 A value type of data passes the identity question as a value. What decides between a record class and a readonly record struct?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: Its size and how many of it the program makes

    Records differ from each other the way classes and structs do: in storage and copying.

  3. Question 3 of 10 A private method returns two values to one caller in the same class, and the values are logged nowhere. Which kind fits, and what would change the answer?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: A named value tuple; the answer changes once the values cross an API or are stored or logged

    Tuples are right inside a class and wrong at its edges.

  4. Question 4 of 10 Why does the plain struct's row in kinds.cs say none under a == b?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: A struct gets no == operator unless it declares one, so the comparison does not compile

    Records and tuples supply ==; a plain struct needs it declared, together with Equals and GetHashCode.

  5. Question 5 of 10 How many heap bytes did kinds.cs measure for 1,000 tuples of (long, string) in an array?

    Type a number.

    Show the answer: Question 5 of 10

    Answer: 16024 bytes

    A value tuple is a struct, so the 1,000 values sit inside the array: 16 bytes each plus the array's 24-byte header, the same as the two other structs.

  6. Question 6 of 10 A team turns a class with a million instances into a record class to fix its equality. What happens to memory?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: Nothing changes: a record class allocates exactly what a class does

    To change storage as well as equality, the type has to become a record struct, which only suits small values.

  7. Question 7 of 10 Which default kind fits a PricingService that holds a cache and calls a tax table?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: A class

    Entities and services are classes.

  8. Question 8 of 10 A Unity component should show its stats in the inspector. Why not declare the stats as a record?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: Unity's serializer does not support records, so the fields would not be saved or shown

    A tool that must handle a type, a serializer or an engine, sets rules that come before the defaults.

  9. Question 9 of 10 A Money type has paise and a currency code, about 16 bytes, and a ledger keeps millions of them. Which default kind fits, and why?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: readonly record struct: a small value, compared by value, stored without an object each

    Small values made in large numbers are what readonly record struct was added for.

  10. Question 10 of 10 Which of these are signs, from the lesson's defaults, that a kind of type should change?

    Choose every answer that is right.

    Show the answer: Question 10 of 10

    Answer:

    • A tuple appears in a public method's signature
    • A struct of 64 bytes is passed by value everywhere
    • A class without equality members is used as a dictionary key

    The defaults double as a checklist for reading code: wrong kinds show up as names lost, keys duplicated and copies made.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.