
Choosing the correct type – class, struct, or record (including record structs) – is crucial for writing efficient, maintainable C# code. Each type has different semantics (reference vs value), default behaviors (mutability, equality), and performance characteristics. This guide provides an expert-level overview of these types in .NET, including new features, and offers best practices for deciding which to use in various scenarios.
Reference Types vs Value Types: Core Differences

Before diving into classes, records, and structs, it’s important to understand the fundamental distinction between reference types and value types in .NET:
- Reference Types: Objects of reference types (e.g. classes, record classes) are allocated on the heap and accessed via a reference (pointer). Assigning one reference variable to another copies the reference, not the object data. Two variables can refer to the same object; modifying the object via one reference is visible through the other. Garbage Collection (GC) is responsible for cleaning up heap objects when no references remain.
- Value Types: Values of value types (e.g. structs, record structs, primitive types like
int) are allocated in-place (on the stack if they are local variables, or inline inside another object or array). Assigning one value variable to another copies the entire data of the value (a deep copy). Each variable has its own copy of the data; modifying one does not affect others. Value types are not individually managed by the GC (they get cleaned up when they go out of scope or their containing object is collected).
Memory layout and locality: An array of a reference type (e.g. an array of class instances) actually stores references, so the actual objects are scattered on the heap. In contrast, an array of a value type stores the values inline in contiguous memory. This means value-type arrays often exhibit better locality of reference and lower allocation overhead than arrays of reference types.
Default equality behavior: By default, reference types use reference equality – two object references are considered equal (== or .Equals) only if they point to the same instance (unless equality is overridden in the class) Value types use value equality by default – two struct instances are equal if all their fields are equal. However, the default implementation for struct equality (ValueType.Equals) uses reflection to compare fields, which is slower than a tailored implementation. We’ll see how records change these defaults.
Immutability: Because value types are copied on assignment or method calls, a mutable struct can lead to confusion if you’re not careful – modifying a copy doesn’t affect the original, and vice versa. For this reason, it’s generally recommended that value types be immutable (unmodifiable after creation). Immutability is also beneficial for thread safety, as discussed later.
The following table summarizes key differences:
Now let’s examine each type category in detail.
Classes – Reference Types in C#
Classes are the bread-and-butter of object-oriented programming in C#. They define reference types, meaning instances are accessed by reference. Key characteristics of classes:
- Reference semantics: Assigning one class variable to another copies the reference, not the object. All references point to the same object in memory. For example:
class Counter { public int Value; }
var c1 = new Counter { Value = 5 };
var c2 = c1;
c2.Value = 10;
Console.WriteLine(c1.Value); // Outputs 10, since c1 and c2 reference the same object.
In this example, c1 and c2 refer to the same Counter instance on the heap. Changing Value through c2 is reflected when accessing it via c1.
- Identity and equality: By default, two class instances are equal only if they reference the same object (reference equality). Unless you override
.Equals(andGetHashCode), a class does not consider two different instances with identical data as equal. Also, the==operator for classes defaults to reference comparison (unless the class overloadsoperator==). This means classes naturally embody identity more than just data. If identity isn’t needed and you want value-based equality, a record (which is a specialized class) might be more appropriate. - Polymorphism and inheritance: Classes support full inheritance hierarchies. You can derive subclasses, use virtual methods, and implement interfaces. If you need an object model with abstraction, encapsulation, and polymorphism, classes are the way to go. Records also support inheritance (but only with other records), whereas structs do not.
- Mutability: Classes can be designed to be mutable or immutable. By default, if you use auto-properties with setters or public fields, they’re mutable. You can restrict mutability by using
privatesetters or only providing data via constructors. There’s no language-enforced immutability for regular classes (unlikereadonly structwhich enforces it at field level). However, C# 9 introducedinit-only properties which allow setting properties only during object initialization (e.g. in an object initializer or constructor) and not thereafter – a useful way to create immutable class instances. C# 11 added therequiredkeyword to force that certain properties must be set during initialization, which helps ensure a class is fully constructed with all necessary data. - Memory and performance: Each class instance is a heap allocation. Too many allocations can put pressure on the GC. Accessing objects involves an extra dereference (to follow the reference) which can impact cache locality. However, copying class references is cheap (just copying a pointer) compared to copying large structs. So classes are suitable for larger objects. If you have a large data structure, passing it around as a class (reference) is usually more efficient than copying an equivalent struct repeatedly. Classes are also necessary if the size grows beyond what a struct should hold or if you want to modify an object in one place and have everyone see the change.
When to use classes: Use a class if an instance is conceptually an entity with identity or large internal state, or if it needs to support inheritance/polymorphism or shared mutable state. Most complex objects in applications (business domain models, UI elements, etc.) are classes. For example, an Order or Customer in a business app would typically be a class – two orders with the same data are still distinct orders (identity matters), and you might have class hierarchies or need reference semantics.
Anti-patterns (when not to use a class): Avoid classes for small, plain data structures where value semantics are more appropriate. For example, using a class to represent a simple pair of values (like an X,Y coordinate) can be suboptimal if you create millions of them – the heap allocations and GC overhead can hurt performance. Also, if you end up writing a class mainly to override equality and hash codes to treat it like a value, that’s a sign a record or struct might be a better fit.
Structs – Value Types in C#
Structs define value types. They are best thought of as representing a single value or a composite of multiple values that form a single concept. Key points about structs:
- Value semantics: Each struct variable holds its own data. Assigning one struct to another copies all its fields (value-copy). Two struct variables cannot refer to the same instance (there’s no separate “instance” on the heap to refer to). For example:
struct Point { public int X; public int Y; }
var p1 = new Point { X = 1, Y = 2 };
var p2 = p1; // copy the values of p1 into p2
p2.X = 42;
Console.WriteLine(p1.X); // Outputs 1 (p1 is unaffected by p2's change)
- Here
p2is an independent copy ofp1. Changingp2.Xdoesn’t affectp1– this is fundamentally different from class behavior. Value semantics are useful for modeling values (like numbers, coordinates) where it makes sense that copying creates an independent value. - No inheritance (except interfaces): Structs cannot inherit from other structs or classes (all structs implicitly inherit from
System.ValueTypebut that’s it). You cannot derive a struct from another struct. They can implement interfaces, but interface calls on struct instances may incur boxing (explained below) unless the call is inlined or the struct implements the interface method explicitly and is used in a constrained generic context. - Default constructor & initialization: Every struct always has a default constructor that zero-initializes its fields. Prior to C# 10, you could not define your own parameterless constructor – but C# 10 lifted that restriction. Now you can define a custom parameterless constructor if needed (it must be public), and you can also initialize fields at their declaration. However, note that using
default(MyStruct)or constructing an array of structs will not call any custom constructor – it will still zero-initialize the struct memory for performance reasons. A user-defined constructor will run only when you explicitly callnew MyStruct(). - Mutability: By default, struct fields can be changed (they’re just like public variables unless you mark them readonly). However, mutable structs can be problematic. Because of value-copy semantics, if a struct is exposed through a property or method, mutating it may end up mutating a temporary copy rather than the original object – a common source of bugs. For example, if you have a property
MyStruct Prop { get; set; }on a class, doinginstance.Prop.X = 5won’t compile becauseinstance.Propreturns a copy. You’d have to dovar tmp = instance.Prop; tmp.X = 5; instance.Prop = tmp;, which is clunky. This is one reason immutability is recommended for structs. If a struct is immutable (all fields readonly, set only in constructor), you never need to worry about these issues. - Readonly structs: C# provides the
readonly structmodifier to indicate that a struct is immutable. All fields of areadonly structmust be readonly, so they can only be set in the constructor. Marking a struct as readonly has two major benefits: (1) It clearly communicates that the type is intended to be immutable; (2) It can improve performance by avoiding defensive copies. If you pass a struct as aninparameter (a readonly reference) or access a struct through a read-only context, the compiler will avoid copying it if the struct’s methods are all safe (for areadonly struct, thethispointer is treated as in read-only mode inside its methods). In practice, you should usereadonly structfor any value type that you intend to be immutable (e.g. an immutablePointorColorstruct). We’ll discuss more in the performance section. - Equality and hashing: As mentioned, structs get value equality by default (all fields compared). But the implementation of
ValueType.Equalsuses reflection to examine fields if you don’t override it, which is not very efficient. For small structs this overhead is usually negligible, but for performance-critical scenarios or larger structs, you should implementIEquatable<T>and override.Equals/GetHashCodefor a struct. This gives you type-specific, non-reflection equality logic. Alternatively, as we’ll see, using a record struct will auto-generate efficient equality for you. - Memory & performance: A struct is allocated wherever it’s needed. If you declare a local variable of struct type inside a method, it resides on the stack (and goes away when the method returns). If a struct is a field of a class, it lives inside the memory of that class (on the heap with the class). If you have an array of a struct, all struct instances live inline in the array’s memory. This inline allocation often means better memory locality and fewer GC allocations compared to classes. Also, allocating and freeing value types is generally cheaper than allocating objects on the heap (stack allocation is very fast and reclaimed automatically). This makes structs attractive for short-lived and frequently created objects. For example, in high-performance code (like games or low-latency systems), using structs for math vectors, complex numbers, etc., avoids constantly allocating garbage-collected objects. However, structs come with a cost when copied. Passing a struct to a method or returning it (by value) will copy the entire struct. If your struct is large (contains many fields or large value fields), this can incur a non-trivial cost each time it’s passed around. The .NET guidelines suggest that structs should ideally be below 16 bytes in size (roughly the size of two pointers). Above that, the performance gains of avoiding GC may be outweighed by the cost of copying, unless you use techniques like
inparameters. If you have a very large piece of data, a class might be more efficient (since only a reference is passed around). - Boxing: One potential performance pitfall with structs is boxing. Boxing is the act of converting a value type to a reference type, e.g. casting a struct to
objector to an interface it implements. This causes the runtime to allocate a box object on the heap and copy the struct into it. Frequent boxing and unboxing negates the performance advantages of structs by introducing heap allocations and GC overhead. For example, adding a struct to a non-genericArrayListor using it with an interface likeIComparable(without generic constraints) will box it. Modern code can avoid many boxing scenarios by using generics (e.g. useList<T>instead ofArrayList, use generic interfaces likeIEquatable<T>). Still, if your struct will need to behave polymorphically or be used in heterogeneous collections, be mindful of boxing. The guideline is: avoid structs if they need to be frequently boxed.
When to use structs: The official .NET design guidelines say to use a struct only if the type has all of these characteristics: “It logically represents a single value, it has an instance size under 16 bytes, it is immutable, and it will not be boxed frequently.” Otherwise, use a class. In practice, common good uses for structs include: numeric types (e.g. System.Decimal is a struct), points, vectors, key-value pairs (like KeyValuePair<TKey,TValue>), tuples, complex numbers, dates (DateTime), and other small data containers. If you have thousands or millions of these, using structs can significantly reduce GC pressure and memory overhead.
Anti-patterns for structs: Avoid using a struct if it’s conceptually not a single value. If it’s more of an entity or has an identity or can exist in different states, a class is better. For example, a Person with a name and age – even if it’s just two fields – should usually be a class, because people have identity beyond just those two values, and you might want to distinguish one person object from another even if they happen to have the same data. Also, do not use large structs. For instance, a struct with dozens of fields or one that contains an array of data is a red flag – passing it around will be expensive. Another bad scenario is a mutable struct that’s part of an API where it can be misused; e.g., a struct that has properties with setters can confuse consumers due to the copy-on-assignment behavior (setting a property on a struct returned from a property getter doesn’t work as one might expect). If you find you need a mutable object with behavior, that’s usually a sign to use a class instead.
Example – Struct vs Class behavior: To illustrate struct vs class, consider a simple 2D vector type implemented both as a class and as a struct:
class VectorClass { public double X; public double Y; }
struct VectorStruct { public double X; public double Y; }
var vc1 = new VectorClass { X = 3, Y = 4 };
var vc2 = vc1;
vc2.X = 10;
Console.WriteLine(vc1.X); // Outputs 10 (vc1 and vc2 are same object)
var vs1 = new VectorStruct { X = 3, Y = 4 };
var vs2 = vs1;
vs2.X = 10;
Console.WriteLine(vs1.X); // Outputs 3 (vs1 is unaffected by vs2)
The class version VectorClass exhibited reference semantics (changing vc2 affected vc1), while the struct version VectorStruct exhibited value semantics (changing vs2 did not affect vs1). Use whichever semantics make sense for the data you’re modeling.
Readonly Structs – Enforcing Immutability
When you declare public readonly struct MyStruct { ... }, you are telling the compiler that all instance fields of MyStruct are readonly and cannot be modified except in a constructor. This guarantees the struct is immutable. For example:
public readonly struct Point2D
{
public readonly int X;
public readonly int Y;
public Point2D(int x, int y) { X = x; Y = y; }
// No setters, all fields are readonly.
}
If you try to assign to X or Y outside of the constructor (even within the struct’s own methods), it will not compile. Also, any instance method in a readonly struct is implicitly considered to not modify state (the this is treated as an in parameter), which means the compiler can safely avoid copying the struct when calling its methods on a readonly reference.
Benefits: Readonly structs are great for performance-sensitive code. They can be passed by reference (in parameter) to avoid copying, and the compiler knows it doesn’t need to make defensive copies because the struct can’t mutate. This can significantly reduce overhead when working with larger structs.
When to use: Always consider readonly struct for small value types that are logically immutable (e.g. an immutable point, a unit like a Currency amount type, etc.). Note that you can combine readonly with struct and with record, which we’ll see later (readonly record struct).
Caveat: If you do need to mutate a struct’s state internally (e.g. a method that adjusts coordinates), then it cannot be a readonly struct. In those cases, ensure the mutations are well-encapsulated and document that the struct is mutable (but again, mutable structs are often best avoided unless there’s a clear performance need and they’re used carefully).
Ref Structs (Span and beyond)
Another special kind of struct in modern C# is the ref struct (e.g. Span<T>, ReadOnlySpan<T>). These are beyond the main scope of this discussion, but in short, a ref struct is a value type that can only live on the stack (it cannot be boxed or stored in heap variables). They are used for high-performance scenarios involving memory buffers. You cannot use record struct or readonly struct with ref struct (and vice versa) – ref structs have their own restrictions. Just be aware that they exist (for example Span<T> allows sliceable memory without allocations) but they are a specialized case when dealing with stack-only data. For typical design decisions (class vs struct vs record), ref structs are not usually a deciding factor unless you are doing low-level optimization or interop scenarios.
Records – Modern C# Data Classes (Reference Type Records)
Records in C# were introduced in C# 9 as a way to create data-centric classes with minimal boilerplate. A record is a reference type (class) by default, just like a normal class, but the compiler adds several features to make it behave more like a value in terms of equality and usability. In other words, records are reference types with value semantics by default.
Key features of record classes (a.k.a. record class or just record):
- Value equality: Record classes override the
.Equalsmethod andGetHashCodeto perform structural equality – two record instances are considered equal if all their properties/fields are equal and they are of the same type. The==and!=operators are also overloaded to use this equality. This is in contrast to normal classes where==by default checks for reference equality. For example:
public record PersonRecord(string Name, int Age);
var r1 = new PersonRecord("Alice", 30);
var r2 = new PersonRecord("Alice", 30);
Console.WriteLine(r1 == r2); // True (content is equal)
Console.WriteLine(object.ReferenceEquals(r1, r2)); // False (different instances)
Here r1 == r2 is true because PersonRecord is a record and the Name and Age match. With a normal class, you’d get false unless you overrode equality manually. This value-based equality makes records ideal for scenarios where you care about the data inside an object, not its identity.
Immutability by convention (init-only): By default, if you declare a record with primary constructor parameters (positional parameters), the compiler generates those as init-only properties (for record classes). That means you can set them during object creation (or via a with expression, see below) but not change them afterwards. In other words, record classes are immutable by default in their API. For example, public record Person(string Name, int Age); will produce a class with two properties public string Name { get; init; } and public int Age { get; init; }. You can override this – records can have mutable properties if you explicitly declare set instead of init – but the primary use-case of records is to make immutable data carriers. Immutability is great for thread safety (shareable without locks) and reasoning about code. (Remember, this immutability is not strictly enforced – it’s by convention and by using init – but you could still write a record with a mutable field if you wanted. It’s just not idiomatic.)
Concise syntax: Records allow a very terse definition. The example above shows positional parameters in the record declaration – this creates the properties, constructor, and more in one line. For a normal class you’d have to write a constructor, property definitions, override equals, etc., all manually. With a record, the compiler does that work. This leads to more concise and readable code for plain data models.
With-expressions (nondestructive mutation): Records support the with expression, which allows you to create a copy of a record with some properties changed. For example:
var person1 = new PersonRecord("Bob", 40);
var person2 = person1 with { Age = 41 };
// person2 is a new PersonRecord instance with Name = "Bob", Age = 41.
// person1 is unchanged.
This is extremely handy for working with immutable objects, because you can “modify” an object by creating a new one based on an existing one, without having to manually copy all properties. Under the hood, for record classes the compiler generates a protected “clone” method and uses it to create a new instance, then applies the property changes. Note that this is a shallow copy: value-type fields are copied, reference-type fields are copied as references (not deep-cloned). So if a record has a reference to a mutable object (say a List<T>), using with will not create new copies of that list – both records would refer to the same list instance unless you replace it in the with. This is important to understand: records (and init-only properties) provide shallow immutability. The record’s own fields can’t be changed after creation, but if one field is a reference to something mutable, that something can still be mutated (for example, a record containing an array can have its array contents modified in place). Therefore, for truly deep immutability, ensure any objects a record holds are either immutable as well or not shared externally.
- Inheritance between records: Record classes can inherit from other record classes (or be
sealedto prevent inheritance, orabstractas base classes). A record cannot inherit from a non-record class, and a non-record class cannot inherit from a record – records form their own hierarchy separate from normal classes. For example,record Student(string Name) : Person(Name);is allowed ifPersonis a record, but you couldn’t deriverecord Studentfrom a regular class. This design ensures that the equality behavior remains consistent across a record inheritance chain. The compiler introduces anEqualityContractproperty in record classes to help with equality in hierarchies – essentially ensuring that two records are only considered equal if they are the same derived type. In practice, this means if you compare a base record to a derived record with the same data in base fields, they will not be equal unless they are actually the same type at runtime (which is usually what you want). Records can override and extend each other, and all the synthesized methods (Equals, GetHashCode, ToString, clone for with, etc.) are handled across the hierarchy automatically. You can even use pattern matching with record types in inheritance scenarios – e.g., match on a base record and it will catch derived types as well. - ToString(): Records override
ToString()to provide a useful string representation by default. A record’sToStringprints the type name and the names and values of its public fields/properties. For example,person1.ToString()from above might outputPersonRecord { Name = Bob, Age = 40 }without you having to override it manually. This is great for debugging and logging. - Pattern matching: Because records often come with built-in value equality and deconstruction, they pair well with pattern matching. You can use property patterns to match on record properties, and positional patterns if you implement
Deconstruct(or rely on positional parameters in C# 9 which allow positional patterns). Records are designed to be easily used in pattern matching scenarios, which is handy in functional programming styles or when processing discriminated unions (which records can model via inheritance).
When to use record classes: Use a record class for immutable data-carrying objects where you want equality to be based on the data, not the identity. Common scenarios include: Data Transfer Objects (DTOs) in APIs (e.g. an incoming request or response shape), read-only configuration/settings objects, result objects (like a result of a computation that has multiple fields), or any time you find yourself creating a class and overriding .Equals and GetHashCode to treat it like a value. Records make such code far cleaner and less error-prone. They are also great for domain value objects in Domain-Driven Design – e.g., types like Money (amount & currency) or Address could be record classes because two Address objects with the same contents can be considered equal in terms of value object semantics.
Records also shine when you have a function that outputs some data grouping – instead of returning a tuple, you can return a record with named properties, which is more self-documenting, and you get equality for free.
If you find you want the benefits of a struct (no allocation) but also the conveniences of a record, that leads us to record structs, which we will cover next.
Caveats and anti-patterns for record classes: Although records are reference types, their value equality can be surprising if you expect reference semantics. If your scenario truly needs unique identity (for example, database entity tracking as in ORMs), records might be a bad fit. ORMs like Entity Framework rely on object identity (reference equality) to track changes; using records (which compare by value and are often immutable) with EF Core is not recommended. In fact, EF Core doesn’t support using record types for entities that need to be updated, because it can’t reassign a new record easily and the equality semantics break their identity tracking logic.
Another thing to watch out for is using records with mutable reference-type fields. For example, a record that contains a List<T> or an array is only shallowly immutable. If two record instances share the same list internally, they will be equal and appear independent, but mutating the list via one will affect the other because the list reference is shared. This isn’t a flaw in records per se, but developers should be mindful to avoid sharing mutable references between record instances, or better yet use truly immutable types (like ImmutableArray<T> or other immutable collections) for the contents of records. The code below demonstrates this subtlety:
public record Group(string Name, string[] Members);
var members = new string[] { "A", "B" };
var g1 = new Group("Team", members);
var g2 = new Group("Team", members);
Console.WriteLine(g1 == g2); // True (same Name and Members reference)
g1.Members[0] = "Z";
Console.WriteLine(g1 == g2); // True (still equal, both see the same modified array)
Console.WriteLine(g2.Members[0]); // "Z" (g2's array was changed via g1)
Even though Group is a record and we didn’t mutate g1 or g2 directly, the array inside was shared and got mutated. So be careful with any mutable object inside a record – the record won’t protect that inner object from changes.
Records vs Classes – Performance: A record is still a class (on the heap), so from a runtime perspective it’s the same cost as any class allocation. The big performance differences are in equality checks and copies:
- Comparing two records for equality is typically more expensive than comparing two class references (because records do field-by-field comparison, whereas class reference equality is just a pointer comparison). However, the comparison is optimized code generated by the compiler, roughly equivalent to what you’d write by hand – and importantly, it’s much faster than default struct reflection-based equality for the same fields.
- Creating a copy of a record via
withinvolves creating a new object and copying field values. This is a shallow copy as discussed. It’s not a deep clone of all reference data.
If your use-case involves a lot of comparisons or keys in dictionaries, record’s pre-generated equality is a big win. If you rarely compare objects and care more about raw creation or memory overhead, a record doesn’t save you there (it might even add slight overhead due to the extra methods it carries, but that’s usually negligible).
New features for records in C# 10-12: C# 10 introduced the concept of record structs (covered next) and allowed record class to be explicitly specified (though just writing record implies class). C# 9/10 gave us with expressions and init-only properties which records leverage. C# 11 added the required keyword which is often used in record declarations to ensure all required properties are initialized (you see required in some record property examples). C# 12 doesn’t change records much directly, but it introduced primary constructors for all classes, which means even non-record classes can now have constructor parameters in the class declaration (similar to how records do). We’ll mention primary constructors later – it’s a syntax enhancement that narrows the ceremony gap between records and regular classes, but it does not give regular classes the auto-equality or with capabilities of records.
Record Structs – Value Type Records
C# 10 introduced record structs, which bring the benefits of records (primarily, concise syntax and generated equality/ToString) to value types (structs). A record struct is declared like public record struct Point(int X, int Y); for example. Here’s what you get with record structs:
- Value type semantics: A record struct is a struct, so it behaves like a value type (allocated inline, copied on assignment, no GC per instance). You decide between using a record struct vs a record class similarly to deciding between struct vs class in general – i.e., based on size, usage, etc. If you want a data-centric type that’s small and high-performance (no heap allocations) but still want the nice record features, record struct is the answer.
- Generated equality and
IEquatable: Like record classes, record structs come with compiler-generated equality. The struct will automatically implementIEquatable<YourType>and properly override.EqualsandGetHashCodeto compare all its fields for equality. It will also get==and!=operators defined (something normal structs don’t get by default). This means two instances of a record struct can be easily compared for value equality, and they can be used as keys in dictionaries or in sets without writing any extra code. Importantly, this generated equality for record structs is more efficient than the defaultValueType.Equalsbecause it doesn’t use reflection – it directly compares each field. In fact, one performance benefit of using a record struct over a plain struct is precisely that: if you need equality, the record struct saves you writing or generating your own, and avoids the slow default implementation. (Benchmarks have shown that a record struct can compare much faster than a poorly implemented large struct equality – up to 20x faster in some cases – simply because it avoids boxing and reflection.) withsupport: Record structs also supportwithexpressions for non-destructive mutation. Since a struct is a value, making a “copy with some changes” is natural (it just makes a copy and changes what you specify). For example:
public record struct Measurement(double Value, string Unit);
var m1 = new Measurement(5.0, "kg");
var m2 = m1 with { Unit = "lb" };
// m2 is a copy of m1 with Unit changed.
// m1 remains 5.0 "kg", m2 is 5.0 "lb".
Under the hood, for record struct, the compiler generates code to copy the fields (likely similar to just Measurement m2 = m1; m2.Unit = "lb";). There is no heap allocation involved in using with on a struct (aside from any internal reference-type fields you might be copying). This is extremely convenient for working with immutable structs because you can mimic the with behavior of record classes with no allocations.
- ToString and printing: Record structs also override
ToString()to include the field names and values by default, just like record classes do, which is handy for diagnostics. - Default mutability: A key difference: record structs are not immutable by default. Unlike record classes (positional record class properties are
init-only by default), a record struct’s positional parameters generate public mutable fields or auto-properties (withget; set;) unless you explicitly make itreadonly. This design decision was made for consistency with regular structs, which are value types that are typically mutable in C# (also for parity with tuples –ValueTupleis mutable – to make record structs feel familiar). If you want an immutable record struct, you should declare it as areadonly record struct. For example:public readonly record struct Point(int X, int Y);will create a struct withXandYasinit-only (effectively readonly after construction). But if you just writerecord struct Point(int X, int Y);withoutreadonly, thenXandYwill be ordinary public settable properties. So be aware: by default record structs are shallowly mutable (you can change their fields/properties), whereas record classes by default use init-only (effectively immutable after init). - No inheritance (like all structs): Record structs cannot inherit from other record structs or classes (they implicitly inherit from
System.ValueTypelike any struct). You can implement interfaces though.
When to use record structs: Use a record struct when you have a small value type that you want to treat as a data record with value equality and possibly use in collections or comparisons. It’s a replacement for writing a struct and then implementing IEquatable, ==, etc., yourself. Typical examples might be something like a measurement reading (value + unit), a coordinate, a tuple-like structure where you want more semantic names and built-in equality.
A record struct is ideal if you want the performance characteristics of a struct (no heap allocation per instance, better locality) but your type is logically a value object with no identity that would benefit from easy equality and immutability. For example, if you’re writing a high-performance application that processes a lot of points or small messages, a record struct Point(x,y) or Message(code, payload) could be useful.
Caveats: Since record structs are mutable by default, if you use them as keys in a dictionary or elements in a set, make sure you don’t mutate them while they’re being used as keys. The dictionary or set uses the hash code from when the key was added; if you change a field that affects equality/hash, you effectively “lose” the entry because lookups will hash to a different bucket. This is the same caution as using any mutable struct as a key. The best practice is to make record structs readonly (immutable) if you plan to use them in such scenarios (and generally, to follow the rule that structs should be immutable). If you need to mutate a record struct, do so in local variables or before adding to collections, etc., just as you would with a normal struct.
Record struct vs regular struct performance: Aside from the equality part, a record struct is just a struct. The presence of extra methods (Equals, etc.) has a negligible performance impact until you invoke them. If you never compare them or use them in hashing scenarios, you might not notice a difference. But if you do need those features, record structs save you from mistakes (like forgetting to update GetHashCode when you add a field) and are usually very efficient. One thing to note: record structs implement IEquatable<T> for themselves. This is good because generic collections like Dictionary<T> or HashSet<T> will use IEquatable if available for value types, avoiding boxing when checking equality. If you hand-write a struct equality without implementing IEquatable, some generic algorithms might still box the struct to call Equals(object). The record struct pattern ensures no boxing in equality comparisons (since it provides the strongly-typed Equals(T other)).
Example – Record struct vs class vs struct equality:
public record class PersonRecord(string Name, int Age);
public record struct PointRecord(int X, int Y);
public struct PointStruct { public int X; public int Y; }
var pr1 = new PersonRecord("Ann", 25);
var pr2 = new PersonRecord("Ann", 25);
Console.WriteLine(pr1.Equals(pr2)); // True, same content
Console.WriteLine(pr1 == pr2); // True, records override ==
var pt1 = new PointRecord(2, 3);
var pt2 = new PointRecord(2, 3);
Console.WriteLine(pt1 == pt2); // True, record struct implements ==
var ps1 = new PointStruct { X = 2, Y = 3 };
var ps2 = new PointStruct { X = 2, Y = 3 };
// Console.WriteLine(ps1 == ps2); // ERROR: operator == not defined for PointStruct
Console.WriteLine(ps1.Equals(ps2)); // True, but uses ValueType.Equals (reflection)
In this example:
PersonRecord(record class) considers two different instances with same data as equal.PointRecord(record struct) likewise considers two structs with same data as equal (and provides==).PointStruct(regular struct) by default has no==operator and.Equalswill do a field-by-field comparison via reflection (we get True in this case, but ifPointStructhad more fields, this is slower; plus no compile-time==).
Combining record struct with readonly: If you want an immutable value type with all the record goodies, use readonly record struct. This will mark all generated properties as init (so you can only set them in the constructor or with-expressions), preventing accidental mutation. For example:
public readonly record struct Dimension(double Width, double Height);
Now Dimension is a value type, you can compare two Dimension instances easily, but you cannot change Width or Height after creation (no setter). Any with expression on it will have to create a new struct (which it does). This pattern is great for safe, immutable small value objects.
Performance and Memory Considerations
Understanding the performance differences between classes, structs, and records will guide you to the right choice:
- Allocation and Garbage Collection: Each class or record class instance is a separate object on the heap, incurring an allocation and later a GC cost. Allocation in .NET is very fast, but if you allocate millions of objects, the GC will work harder to clean them up (especially if they survive to older generations). Structs and record structs, on the other hand, may be allocated on the stack (if they are local variables) or inline in an array or containing object. These do not add pressure to the GC for each instance. Therefore, using structs for short-lived objects or large arrays of objects can significantly reduce GC load. For example, an array of 1000
PointStructwill be one single allocation for the array (plus 1000 * 8 bytes of data inline) whereas an array of 1000PointClasswould involve 1001 allocations (one for the array of references, and 1000 for eachPointClassobject on the heap). The struct array will also likely have better locality (the points are stored contiguously in memory). - Copying cost: Copying a reference type (class) is just copying an 8-byte reference (on 64-bit), regardless of the object’s size. Copying a value type means copying all its fields. If you have a large struct (say 64 bytes), every time you pass it to a method or return it, you copy those 64 bytes. If the struct is passed in a tight loop or recursive calls, this can add up. Large structs can thus impact CPU cache and memory bandwidth.
- Mitigation: If you truly need a large value type for some reason, C# offers the
inparameter modifier to pass it by reference without copying (ensuring it’s not modified). Coupled with areadonly struct, this can let you use large structs safely. But often, needing that is an indicator to consider a class instead. - As a rule of thumb, keep structs small (the often-cited guideline is <16 bytes) for optimal performance. If your struct grows beyond that, evaluate if the benefit (avoiding GC) outweighs the cost (copying). Modern CPUs are fast at copying even 32 bytes, so don’t be too afraid of medium-sized structs, but measure if performance is critical.
- Mitigation: If you truly need a large value type for some reason, C# offers the
- Boxing overhead: As mentioned, using a struct where a reference is expected will cause a box allocation. This typically happens when:
- Passing a struct to a parameter of type
objector an interface it implements (without generic). - Storing a struct in a non-generic collection.
- Calling a virtual method that comes from
System.Object(like.ToString()or.Equals(object)) on a struct, when not constrained generic – though compilers/JIT have gotten smarter at avoiding some boxing by inlining calls. In any case, each boxing is an allocation + copy + later GC. If you design a struct type, consider implementingIEquatable<T>and perhaps overriding.ToString()to minimize boxing in common use cases (records do this for you). - Records and boxing: Record classes avoid boxing issues entirely (they are already reference types). Record structs implement
IEquatable, so if used in generic contexts (like aDictionary<RecordStruct, V>), they avoid boxing for equality checks. But if you use a record struct as anobjector non-generic interface, it will still box.
- Passing a struct to a parameter of type
- Polymorphism and casting: If you need polymorphic behavior, classes handle this efficiently with virtual dispatch (just a vtable lookup). If you try to achieve polymorphism with structs (e.g., using interfaces or unions of structs), you might end up boxing or using patterns that are less direct. This is why most polymorphic designs use classes. If you find yourself wanting to cast a struct to some base type frequently, reconsider if it should be a class or a record class.
- Memory layout and padding: This is a low-level detail, but classes have an object header (at least 16 bytes on 64-bit for sync block and type pointer). Structs have no header when inlined; they might have padding for alignment. This means a class with two
intfields might take more memory overall than a struct with twointfields (because the class has the object header overhead, whereas an array of that class has 2 ints + reference per element + header per object, etc.). So for very large counts of objects, structs can be much more memory efficient. - Cache locality: When iterating through data, it’s usually faster if the data is contiguous in memory (to leverage CPU cache prefetching). If you have a list of 1000 small objects (classes), you have 1000 pointers and 1000 objects scattered on the heap (unless the allocator got them in order by chance). Iterating might cause cache misses jumping around memory. If you have a list of 1000 structs, they’re stored right in the list’s internal array buffer contiguously. Iteration will be cache-friendly. This is one reason high-performance systems (like game engines) often favor structs for game world data that is processed in large batches.
- Large object heap (LOH): If you have very large arrays (85k+ bytes) of value types, they go on the LOH (which has different GC behavior). If the array is of a reference type, the array itself goes on LOH but the contents are still separate small heap objects. If you absolutely must manage big chunks of data, sometimes using struct arrays can even help avoid LOH fragmentation by keeping things within normal heaps, but that’s a corner case.
- New features (C# 12) – Inline arrays: C# 12 introduced inline arrays, which allow you to declare fixed-size buffers in a struct without unsafe code. This is a very specialized feature (marked with
InlineArrayattribute) where you might see usage in high-performance scenarios (e.g. a struct that internally contains a fixed-size buffer of bytes or similar). It basically turns a struct into something like a mini-array of a given length embedded in the struct. This feature is beyond typical use-cases, but it’s another tool for squeezing extra performance by avoiding separate allocations. If you find yourself needing this, you’re already in extreme optimization territory.
Summary of performance advice: If performance and memory are a concern (for example, writing low-latency code, games, or processing huge data sets):
- Favor structs (or record structs) for small, frequently created data that you will store in arrays or pass around a lot, to reduce GC pressure.
- Ensure those structs are reasonably small (think a few fields) and preferably immutable to avoid the pitfalls of mutable value types.
- Use classes for larger objects, complex states, or when identity matters, to avoid excessive copying.
- Use record classes for complex data where you benefit from equality and immutability, understanding they carry normal class costs.
- Use record structs for small data where you want both value semantics and auto-generated behavior.
- Avoid scenarios that force excessive boxing of structs (use generics or adjust design).
In modern .NET, the JIT and runtime have become quite good at handling structs (e.g., RyuJIT can enregister small structs, and .NET 7+ has some optimizations for structs calling interface methods, etc.). But the classical advice still holds as a guideline. Always measure if you are in doubt – sometimes the differences only matter at large scale.
Immutability and Thread Safety
One major theme with choosing the right type is immutability. Immutability means once an object (or value) is created, it never changes. This has big implications:
- Thread safety: Immutable objects are inherently thread-safe for reading. If no thread can modify an object, then all threads see the same state always, and you don’t need locks for synchronization on shared read-only data. For example, if you create an immutable record
Configand share it among threads, they can all read its properties without any risk of race conditions. If a change is needed, you create a newConfigobject and maybe use an atomic swap to publish it, but the old one remains valid for any threads that still had a reference. This is a much simpler model than locking around a mutable object. - Consistency: With an immutable type, you never have to worry about someone half-way modifying it and leaving it in an inconsistent state (since no modifications happen after construction). All data can be set at construction time (or via
withwhich constructs a new instance). - Hashing and collections: If you use an object as a key in a dictionary or in a set, it’s important that it doesn’t mutate in a way that affects equality or hash code while it’s in the collection. Immutability guarantees that – once the key is created, it’s safe to use. (The C# docs explicitly mention this benefit for records: “Immutability can be useful when you need a data-centric type to be thread-safe or you’re depending on a hash code remaining the same in a hash table.)
Classes vs Records vs Structs in immutability:
- A regular class can be made immutable by making all fields private and only setting them in the constructor, and exposing only getters (no setters). Use
readonlyfields or get-only auto-properties (orinitin C# 9+). There’s no language keyword to make a class immutable, it’s just by convention. - A struct can be made immutable by making fields readonly (you can also mark the struct
readonlyto enforce it). - A record class encourages immutability by using
initaccessors for positional properties. You could still add a non-init property if needed, but typically you don’t. So records push you toward an immutable design. Additionally, the with-expression and value equality strongly assume you’re treating the record as an immutable value. It’s good practice to keep records immutable (and all examples in docs show them that way). - A record struct should ideally be marked
readonlyif you want immutability, otherwise it’s mutable by default. So usereadonly record structfor an immutable value type.
Multi-threaded code considerations:
- If you have a shared mutable class (not a record, just a normal class with fields that change), you must ensure thread-safe access (locks, volatile, etc.) if multiple threads touch it. For example, a singleton service class that maintains some state likely needs locking or other synchronization if accessed concurrently.
- If you use an immutable record/class, multiple threads can read it freely. If one thread needs to “modify” the data, it would create a new instance (e.g., using a
withexpression or constructing a new record with new values) and then typically you’d update a reference to point to the new instance (which might need to be done atomically). This is a common pattern in functional-style concurrency: share immutable data and use atomic swaps for updates (like usingInterlocked.Exchangeor a concurrent dictionary for versioned data). - For structs, typically these are used as local values or in collections. If a struct is on the stack, each thread has its own stack, so no issue. If you have a struct in a static field or a mutable struct in a heap object that multiple threads might see, the usual thread-safety concerns apply. The difference is if one thread writes to a struct’s field and another reads it, it’s like reading/writing any primitive value – you may need synchronization or memory barriers to ensure freshness and avoid torn reads (for larger than pointer-sized structs).
- If you have a large struct (more than 8 bytes on 64-bit), writing it is not guaranteed atomic. For example, writing a 16-byte struct might happen in two 8-byte operations. If another thread can read that memory concurrently, it could read a half-updated value. Thus, either make sure concurrent writes don’t happen or use locking/
Interlockedoperations if available (Interlocked only works up to 64-bit value at a time, beyond that you must lock). - Record classes in multi-threading: Usually safe to share if you don’t mutate them. If they are truly immutable (all init-only or readonly fields, and those fields are either value types or other immutable types), you can treat them as constant data. If you have to update, use
withto create new instances. This is a great fit for scenarios like a global configuration that might change rarely – you can replace the object and all threads see the old or new config (they won’t see a partially modified config as could happen if you mutated a large object’s fields one by one under insufficient lock).
Example – thread safety with immutable vs mutable:
Suppose we have a configuration object with two fields. First as a mutable class:
class Config
{
public int A;
public int B;
}
Config config = new Config { A = 1, B = 2 };
// Thread 1:
config.A = 5;
config.B = 10; // Without a lock, another thread could see A changed but B not yet.
// Thread 2:
Console.WriteLine(config.A + config.B);
In the above, if thread 2 runs while thread 1 is in between setting A and B, it might see an inconsistent state (A updated, B old). You’d fix that with a lock around updates and reads.
Now as an immutable record:
record ConfigRecord(int A, int B);
ConfigRecord configRec = new(1, 2);
// Thread 1 (update):
configRec = configRec with { A = 5, B = 10 }; // creates a new record atomically
// Thread 2:
Console.WriteLine(configRec.A + configRec.B);
Here, even without a lock, thread 2 either sees the old ConfigRecord (1,2) or the new one (5,10) – but never a mix. The assignment to configRec reference is atomic, so thread 2 will see either one whole object or the other. This eliminates a whole class of timing bugs. (If configRec was a plain class and we reassigned it, that’s also atomic for the reference, but mutating an existing one is not atomic field by field.)
Entity Framework and similar tools: As mentioned, ORMs typically require classes with setters (mutable) because they construct objects and then set properties, or they track objects and modify them. In those scenarios, you may not want records. Immutability can clash with frameworks that assume a no-arg constructor and property setters to hydrate objects. You can still use records by giving them parameterless constructors or making properties settable, but that takes away some of the benefits. Always consider the requirements of libraries you use.
Summary: Prefer immutable structs/records/classes for safety and simplicity, especially in multi-threaded programs. Use readonly struct or record classes with init-only properties to enforce this. If you need mutability (for instance, a large object that changes frequently), ensure you manage concurrency (locks or other synchronization) and consider whether a more functional approach (replace-whole rather than in-place update) could simplify the logic.
Best Practices and Decision Guidelines
To decide between class, struct, record class, or record struct, consider the following questions and guidelines:
- Does it behave like a single value? If the type you’re modeling is conceptually a single value (like a number, coordinate, date, etc.) and especially if it’s small (a few fields) and immutable, a struct is a good choice. Examples: a tuple of two values, a complex number, a color (with RGBA components). By using a struct, you make it clear that two instances with the same data are interchangeable in terms of value.
- Is it small (roughly < 16 bytes) and short-lived or part of an array? This is the scenario where struct shines. You avoid heap allocations and get better locality. If the answer is yes, lean towards struct. If the type has many fields or wraps a big chunk of data, lean towards class to avoid copying overhead.
- Will it be frequently boxed or used via interfaces? If yes, struct might become costly due to boxing. For example, if you want to put these in a non-generic collection or call interface methods often, you might prefer a class (or ensure the struct implements interfaces to avoid boxing in generics). If no (you can keep it in generics and value contexts), struct is fine.
- Do you need reference semantics? If you need to be able to pass references around so that different parts of code operate on the same object instance, you need a class (or record class). A struct always operates on copies, so sharing requires wrapping it in a class or similar. For example, if you have a complex graph of objects with shared connections, classes are necessary. If your data is tree-structured or you want identity, use class.
- Do you plan to use inheritance/polymorphism? Only classes (and record classes) support inheritance. If you need a base type and derived types, or you need to pass objects via base class references, you must use a class. (Records can be used for polymorphism among themselves if all types are records.) If instead you would use composition or just separate types, this is not a deciding factor.
- Is the main purpose to hold data with minimal behavior? If yes, consider a record. If the data meets the struct criteria (small, value-like), consider a record struct. If the data might be larger or you prefer reference semantics (perhaps to avoid copies), use a record class. Records are ideal when you want built-in value equality, immutability, and expressive code for data carriers. DTOs for web API, messages between systems, read-only settings, etc., are perfect as records. You get equality and
ToStringfor free, which is often useful for logging or comparing results. - Do you need identity and mutability (like entities)? Use a class, not a record, if you intend to mutate the object’s state over time or if each instance represents a unique identity that shouldn’t be merged by equality. For example, an
Orderwith an ID that might change status over time should probably be a class. Two orders with the same details are not the same order unless they share identity. Similarly, objects that will be updated in an ORM or through UI binding might be better as classes with proper property change notifications, etc., rather than records. - Will the instances be used as keys in collections or require a lot of equality checks? If yes, using a record (class or struct) or at least implementing proper equality in your type is important. For keys, if the type is small, a record struct is great (no alloc, and proper hash/equality). If the type is larger, a record class is simpler (and it will generate a good hash code that takes into account all fields). If you used a plain class without overriding equality, you’d only be able to use reference equality (which may not be what you want for logical keys). If you used a plain struct without implementing equality, the default might be too slow or not what you expect.
- Consider
readonly: If you choose struct or record struct and the type is meant to be immutable, mark itreadonlyfrom the start. This prevents accidental field modifications and helps the compiler optimize. Similarly, for classes, use init-only or private setters to enforce immutability in usage. - New C# 12 Primary Constructors: If you find yourself annoyed with writing constructors for simple classes, remember that now you can use primary constructor syntax on classes and structs (not just records) to streamline initialization. This doesn’t affect whether you choose class vs struct vs record, but it’s a syntax convenience. For example, you can write
class Range(int Start, int End) { public int Length => End - Start; }in C# 12, which is similar to a record declaration syntax but it’s still a normal class with no synthesized equality orToString. Primary constructors can thus be used on classes where you want concise construction but either don’t need full record semantics or want to control behavior manually. - Avoid mutable structs when exposed to external code: If you have a public API, do not return a mutable struct that is meant to be modified – this leads to the problem that consumers might not realize they are mutating a copy. If you must have a mutable struct internally (for performance), keep it internal or private and expose operations to modify it in a controlled way (or better, expose it as readonly to consumers).
- Large data and Span: If you have truly massive data structures where even class overhead is too much, and you need to manipulate raw memory, consider using Span<T> or Memory<T> for the heavy lifting instead of extremely large structs or arrays of objects. That’s beyond the class vs struct vs record decision, but it’s a direction for high-performance scenarios (Span is a ref struct for slicing memory).
To put it succinctly (the “nutshell” from InfoWorld):
- Use classes when you need objects with identity, mutable state, or complex behavior/relations.
- Use structs for lightweight, immutable values where copying is cheap and you want to avoid heap allocations.
- Use records when the focus is on the data being stored rather than behavior – especially when you want immutability and equality without boilerplate (record class for reference type, record struct for value type).
Finally, let’s solidify understanding with a few practical examples of good vs bad usages:
Example 1: Coordinates (Struct vs Class)
Good: A 2D point with X and Y should often be a struct (say PointStruct) because it’s just two numbers. It’s small (8 bytes), value-like, and you might have lots of them (e.g., in a graphics or geometry algorithm). Making it a struct avoids thousands of tiny allocations. Mark it readonly if you don’t need to change X or Y after creation. Even better, you could make it a readonly record struct Point(int X, int Y) to get equality and a nice ToString for debugging.
Anti-pattern: If you made Point a class and created an array of a million points, you’d allocate a million objects on the heap. Iterating through them would jump all over memory, likely hurting performance. You’d also have to override Equals to compare points by value, or risk bugs if someone uses == thinking it checks coordinates but it actually checks reference.
Example 2: Data Transfer Object (Record vs Class)
Suppose you have a web API method that returns a WeatherInfo with properties like Temperature, Humidity, etc. Good: Define public record WeatherInfo(double Temperature, double Humidity, string Description);. This is concise, makes it clear it’s just data, and you get equality (which might help in tests or caching logic). It’s also inherently immutable (no one will accidentally change Temperature after creation) which is fine for a DTO.
Alternative: If you used a class, you’d have to either make properties read-write (which isn’t needed here) or write more code to make it immutable. And you’d lack auto ToString (not a big deal but helpful in logs). The record is a clean win for readability and correctness here.
Anti-pattern: Using a mutable class with public setters for such data might tempt consumers to modify it, which doesn’t make sense if it’s meant to represent a snapshot of info. Also, comparing two such objects wouldn’t work as expected unless you override equality.
Example 3: Large Struct (Bad Use of Struct)
Imagine you have a matrix of 4×4 doubles (16 doubles = 128 bytes) and you make a struct Matrix4x4 for it (which XNA and some frameworks actually do). 128 bytes is beyond the 16-byte guideline, but frameworks sometimes still use such structs for performance to avoid heap. However:
- If you frequently pass
Matrix4x4by value to methods, you’re pushing a lot of data on the stack each time. This can hurt performance. - If you store a
Matrix4x4in a field and mutate it often, each mutation in certain contexts might cause copying (if not careful). Better approach: Either ensure to pass it byinreference in methods (e.g.void Transform(in Matrix4x4 m)) to avoid copying, or consider using a class if the usage pattern involves many transformations. Another approach is to keep it as a struct but accept the trade-off, given 128 bytes is not huge and modern CPUs can handle it – but be mindful. Good scenario for such a struct: If you have an array of matrices and you need to process them in bulk, having them as structs (contiguous in memory) might be faster overall than an array of class references. This is where you must measure the specific workload. In general, try not to proliferate very large structs across your API.
Example 4: Mutable Struct Pitfall
Consider a struct representing a range: struct Range { public int Start; public int End; } (mutable). Now assume a class Document has a property public Range Selection { get; set; }. What happens if someone tries to extend the selection?
document.Selection.End = 50;
This won’t actually compile – the C# compiler prevents modification of a struct property return value because it’s a copy. The user must do:
var sel = document.Selection;
sel.End = 50;
document.Selection = sel;
This is error-prone (easy to forget to set it back) and inefficient (extra copy). Better: make Range immutable (readonly struct Range) and provide methods on Document to adjust the range, or make Selection a class if you intend it to be mutable in place. Or use a record struct and a with: e.g., document.Selection = document.Selection with { End = 50 };. That actually works nicely if Selection is a record struct, because with will produce a new Range value and set it back in one expression. Nonetheless, returning a mutable struct from a property is generally a bad idea. Prefer records or immutability, or use methods rather than exposing the struct directly.
Example 5: Records in a Heterogeneous Collection
If you have a collection where you mix different types, e.g., a list of object that can contain various things, using records won’t magically help performance – they’ll still be reference types in that context. But if you have a homogeneous collection (like List), it behaves like a list of classes (since record is class). If you truly need a collection of mixed data, you probably are using classes anyway. Alternatively, maybe you should be using a discriminated union pattern (which in C# can be done via record inheritance or an enum + struct union).
Example 6: Identity vs Equality
You have a class User { public string Name; public string Email; }. Two different users could have the same name and email by coincidence, but they are different users. So you would use a class and keep reference equality (or maybe use an ID field for identity). If you accidentally made it a record thinking “User has two properties, let’s use a record”, you’d get an incorrect equality definition – two distinct user instances with same Name/Email would .Equals each other. That could cause issues if you put them in a set (it would treat them as duplicate) or compare them. So do not use a record when identity matters. In this case, either stick with class (no equality override) or if needed implement equality based on a unique ID if you have one, but typically identity is just reference.
Example 7: Pattern Matching with Records
Say you create a record hierarchy for a shape: abstract record Shape; record Circle(double Radius) : Shape; record Rectangle(double Width, double Height) : Shape;. This is a nice usage of records to represent variants of shapes (kind of like a discriminated union). You can then easily pattern match:
Shape s = new Circle(5);
string desc = s switch {
Circle(var r) => $"Circle with radius {r}",
Rectangle(var w, var h) => $"Rectangle {w}x{h}",
_ => "Unknown shape"
};
Records made it easy to define these and they come with equality (two Circle(5) will be equal). If you used classes, you could still do this, but you’d have to implement equality and likely override ToString manually.
In conclusion, use this guide as a reference when you’re unsure which type to choose. In many cases, the difference won’t drastically affect a small application, but as your program grows or if performance becomes critical, making the right choice between class, struct, or record will pay off in clarity, correctness, and efficiency. To recap:
- Use struct/record struct for small, immutable values, especially if you’ll have many of them or performance is key (no per-item GC overhead).
- Use class for larger objects, entities with identity, or when you need polymorphism and shared references.
- Use record class for data-focused classes that benefit from immutability and value equality (and you want to avoid writing boilerplate).
- Leverage new C# features like
readonly struct,record struct, and primary constructors to make your types safer and code cleaner with minimal overhead. - Always consider immutability for thread safety and simpler reasoning – make things immutable unless there’s a good reason not to.
By following these guidelines and understanding the differences outlined above, you can confidently choose the appropriate type for any given scenario in modern C#/.NET. Happy coding!
Sources:
- Microsoft .NET Design Guidelines – Choosing Between Class and Structlearn.microsoft.comlearn.microsoft.com
- C# 9.0 and 10.0 Documentation – Records (reference types and structs)learn.microsoft.comlearn.microsoft.com
- Microsoft Learn – Records – C# Reference (equality, immutability, etc.)learn.microsoft.comlearn.microsoft.com
- InfoWorld – When to use classes, structs, or records in C# (usage recommendations)infoworld.cominfoworld.com
- Stack Overflow discussion – Record vs Class vs Struct (community insights on usage)stackoverflow.comstackoverflow.com

Leave a comment