Why This Matters When AI Can Just Write the Code
It’s a fair question: if you can ask an AI assistant to generate a class, a record, or a struct in seconds, why spend time learning the difference yourself?
An AI tool will happily generate any of the four — a class when you needed a record, a mutable record struct when you needed a readonly one, a struct the size of a small database row when a class would have been far cheaper to pass around. It will do this confidently and without warning you, because generating syntactically valid code isn’t the same as generating the right code for your situation. The tool doesn’t know whether your Order needs identity that persists over time or whether two orders with equal values really are “the same order.” Only you know that, because it depends on what your program actually needs to do — and that’s a design decision, not a syntax problem.
There’s also a more immediate, practical reason: you cannot evaluate, debug, or safely modify code you don’t understand — whether you wrote it or an AI did. If an AI hands you a record struct and a bug shows up because a value silently mutated somewhere it shouldn’t have, you need to already know that record struct is mutable by default to even suspect where to look. If a HashSet<T> lookup mysteriously starts failing after an update, you need to already understand the connection between mutability and hashing to diagnose it, rather than pasting the bug back into a chat window and hoping. AI is a powerful accelerant for someone who already has the judgement to steer it and the literacy to check its output — it’s a much weaker tool, and sometimes actively harmful, in the hands of someone using it as a substitute for that judgement rather than an extension of it.
The reasoning in this post — reference vs. value semantics, structural vs. identity equality, the cost of copying, the fragile-base-class-style thinking around mutability — isn’t C#-specific trivia. It’s how you’ll evaluate design tradeoffs in any language you touch for the rest of your career, AI-assisted or not. Learning to ask “does this thing have an identity, or is it just data?” is a transferable skill. Knowing the exact keyword to type is not, and that part genuinely is something a tool can help with once you already know what you’re asking for.
The Core Mental Model
Every C# type sits at the intersection of two independent ideas, and understanding these two ideas is the key to understanding everything else in this guide.
The first idea is storage and copy semantics: is the type a reference type or a value type? A reference type lives on the heap, and a variable of that type doesn’t hold the object itself — it holds a reference (essentially an address) pointing to where the object lives. When you assign one reference-type variable to another, you’re copying the address, so both variables end up pointing at the same object. A value type, by contrast, lives directly wherever it’s declared — on the stack, or inline inside an array or another object. When you assign one value-type variable to another, the entire contents get copied, producing two completely independent values.
The second idea is equality: when you compare two instances with == or .Equals(), does the language check whether they’re the same object (reference/identity equality), or whether they contain the same data (structural/value equality)?
These two axes combine to describe our four type kinds:
classis a reference type with reference equality by default. Twoclassinstances with identical field values are still considered “different” unless you override equality yourself.structis a value type with value equality — but that default equality comes fromSystem.ValueTypeand is implemented via reflection, which is functionally correct but noticeably slow.recordis a reference type, just likeclass, but the compiler automatically generates fast, correct, structural (value) equality for you, along with a few other conveniences.record structis a value type, just likestruct, but again with the compiler automatically generating fast, correct, structural equality (no reflection this time either).
So a helpful way to think about it: a record is a class that the compiler has enriched with value semantics, and a record struct is a struct that the compiler has enriched in the same way. The “record” part of the name always means “give me generated equality, a readable ToString, and with-expressions” — it’s orthogonal to whether the underlying thing is a reference type or a value type.
Reference Types vs. Value Types, Explained Slowly
Since this distinction underlies everything else, it’s worth walking through carefully with an example, because it’s the part students get wrong most often.
class PointClass { public int X, Y; }
var a = new PointClass { X = 1, Y = 2 };
var b = a; // b now points to the SAME object as a
b.X = 99;
Console.WriteLine(a.X); // prints 99! Changing b changed a, because they're the same object
With a class, the variable a never actually contains a PointClass — it contains a reference to a PointClass sitting somewhere on the heap. When we write var b = a;, we’re copying that reference, not the object. Now a and b are two different “pointers” aimed at one shared object, so mutating through b is visible through a too.
Now compare that with a struct:
struct PointStruct { public int X, Y; }
var a = new PointStruct { X = 1, Y = 2 };
var b = a; // b gets an independent COPY of a's data
b.X = 99;
Console.WriteLine(a.X); // still prints 1 — a and b are separate values now
Here, a directly holds the X/Y values (no indirection through a heap address). When we write var b = a;, the runtime copies all of a’s bytes into b. From that point on, a and b are completely unrelated — changing one has no effect on the other. This copying happens every time you pass a struct into a method, return it from a method, or store it into a new variable, which is why struct size matters for performance: a big struct means a lot of copying.
This distinction directly explains why reference types can be null and value types generally can’t: null means “this reference doesn’t point to anything,” which is meaningless for a value type that isn’t a reference at all — it always has to hold some actual data.
record follows the class behavior above exactly (it’s a reference type), and record struct follows the struct behavior exactly (it’s a value type). Nothing about the word “record” changes this — it only changes what happens when you compare two instances or print them, which is the next topic.
Equality — Worked Examples for All Four
class: reference equality by default
class PointClass { public int X, Y; }
var a = new PointClass { X = 1, Y = 2 };
var b = new PointClass { X = 1, Y = 2 };
var c = a;
Console.WriteLine(a == b); // False: different objects, even though the data matches
Console.WriteLine(a.Equals(b)); // False: same reason
Console.WriteLine(a == c); // True: c is literally the same object as a
This often surprises beginners: a and b look identical on paper, but == returns false. That’s because class equality by default asks “are these the exact same object in memory?” — not “do these objects contain the same values?” If you want value-based comparison on a plain class, you have to override Equals, GetHashCode, and optionally ==/!= yourself, which is real, non-trivial boilerplate that’s easy to get subtly wrong (forgetting a field, mishandling null, inconsistent hash codes, etc.).
struct: value equality, but slow by default
struct PointStruct { public int X, Y; }
var a = new PointStruct { X = 1, Y = 2 };
var b = new PointStruct { X = 1, Y = 2 };
Console.WriteLine(a.Equals(b)); // True — value equality, as you'd hope
// But notice:
// Console.WriteLine(a == b); // Compile error! CS0019 — a struct doesn't even get a `==` operator by default
Good news: Equals does compare values correctly out of the box, because struct inherits it from System.ValueType, which uses reflection to inspect and compare every field. Bad news: reflection is slow — noticeably so if this Equals gets called in a hot loop or inside a HashSet/Dictionary. There’s also no == operator generated at all, so you can’t even write the natural comparison syntax without adding it yourself. In professional code you’d typically implement IEquatable<T> plus operator overloads by hand for any struct you plan to compare often — which is precisely the boilerplate record struct eliminates.
record: fast, compiler-generated value equality
record PointRecord(int X, int Y);
var a = new PointRecord(1, 2);
var b = new PointRecord(1, 2);
Console.WriteLine(a == b); // True — the compiler generated a real == operator for us
Console.WriteLine(a.Equals(b)); // True
Console.WriteLine(ReferenceEquals(a, b)); // False — they're still two separate heap objects
Notice the last line: a and b are still distinct objects living at different heap addresses (record is a reference type, remember), but the compiler-generated Equals/== compare their contents rather than their addresses, so the comparison behaves the way most people intuitively expect.
Records also correctly take runtime type into account when there’s inheritance involved, which matters a lot once you start building hierarchies:
record Animal(string Name);
record Dog(string Name, string Breed) : Animal(Name);
Animal a = new Dog("Rex", "Lab");
Animal b = new Dog("Rex", "Lab");
Animal c = new Animal("Rex"); // same Name, but a plain Animal, not a Dog
Console.WriteLine(a == b); // True — same runtime type (Dog) and same field values
Console.WriteLine(a == c); // False — even though "Name" matches, the runtime types differ
record struct: fast value equality, no reflection
record struct PointRecordStruct(int X, int Y);
var a = new PointRecordStruct(1, 2);
var b = new PointRecordStruct(1, 2);
Console.WriteLine(a == b); // True — compiler-generated, field-by-field comparison, no reflection involved
This is the “best of both worlds” case for equality: you get the value semantics of a struct (no heap allocation, independent copies) combined with the fast, correct, compiler-written comparison logic that record provides. You never have to write Equals/GetHashCode/== by hand, and you never pay the reflection tax that plain struct equality incurs.
ToString() — Why Records Are So Much Nicer to Debug
When you print a plain class or struct without overriding ToString(), you get almost nothing useful:
class PersonClass { public string Name = "Alice"; public int Age = 30; }
Console.WriteLine(new PersonClass());
// Output: PersonClass <- just the type name, no data at all
Records generate a genuinely useful ToString() automatically, listing every property and its current value:
record PersonRecord(string Name, int Age);
Console.WriteLine(new PersonRecord("Alice", 30));
// Output: PersonRecord { Name = Alice, Age = 30 }
The exact same thing happens for record struct. This might look like a small convenience, but in practice it’s one of the biggest quality-of-life differences students notice immediately: when you’re debugging, logging, or writing a failing test assertion, seeing PersonRecord { Name = Alice, Age = 30 } printed straight to the console instead of just PersonRecord saves an enormous amount of time you’d otherwise spend writing Console.WriteLine($"{p.Name}, {p.Age}") by hand, or attaching a debugger just to inspect field values.
Mutability & with-Expressions
record is immutable by default
When you declare a record using the concise positional syntax, each parameter becomes a property that can only be set once, at construction time (technically, it’s init-only rather than get; set;):
record Person(string Name, int Age);
var alice = new Person("Alice", 30);
// alice.Age = 31; // Compile error! Age can only be set during initialization, never after
This is a deliberate design choice, not an accident. Once you’ve built a Person, nothing else in your program can quietly reach in and change its Age behind your back — which eliminates an entire category of bugs where one part of a codebase mutates shared data and another part is surprised by it. It also makes instances inherently safe to share across threads, since there’s no mutable state to synchronise.
But you still need a way to “change” a value conceptually — for example, “give me a copy of Alice, but one year older.” That’s what with is for:
var olderAlice = alice with { Age = 31 }; // creates a brand-new object
Console.WriteLine(alice); // Person { Name = Alice, Age = 30 } — completely unchanged
Console.WriteLine(olderAlice); // Person { Name = Alice, Age = 31 } — a new, separate instance
with takes the original object, copies every property you didn’t mention, overwrites the ones you did mention, and hands you a new object — all in one expression, with no manual copy-constructor to write or maintain.
record struct is MUTABLE by default — the single biggest gotcha in this whole topic
Students coming from record naturally assume record struct behaves the same way. It does not, and this trips up almost everyone the first time:
record struct Point(int X, int Y);
var p = new Point(1, 2);
p.X = 99; // Perfectly legal! record struct properties are ordinary mutable get/set by default
Console.WriteLine(p); // Point { X = 99, Y = 2 }
Unless you explicitly say otherwise, a record struct’s positional properties are regular, mutable { get; set; } properties — the exact opposite of what record does. If you want a record struct to behave immutably, the way record does automatically, you need to add the readonly modifier yourself:
readonly record struct Point(int X, int Y);
var p = new Point(1, 2);
// p.X = 99; // Now this is a compile error, exactly like the record case
var moved = p with { X = 99 }; // use `with` instead, same syntax as before
The practical takeaway for students: when in doubt, write readonly record struct, not just record struct, unless you have a specific, deliberate reason to want a mutable value type (a common one being a small “accumulator” struct you intentionally mutate in place inside a tight loop to avoid allocating a new instance on every iteration).
class and struct have no with support at all
Neither plain class nor plain struct gets any of this for free. If you want non-destructive “copy with one field changed” behaviour on a class, you write it by hand:
class PersonClass
{
public string Name; public int Age;
public PersonClass Clone(int newAge) => new PersonClass { Name = Name, Age = newAge };
}
This works, but it’s boilerplate that has to be manually kept in sync every time you add a new field — miss one, and your “clone” silently drops data. This is exactly the class of bug the compiler-generated with on records eliminates.
Inheritance — What’s Allowed and What Isn’t
class supports the full, familiar inheritance model you’d expect from an object-oriented language: unlimited depth, abstract base classes, virtual/override methods, sealed to stop further derivation, and so on.
class Shape
{
public virtual double Area() => 0;
}
class Circle : Shape
{
public double Radius;
public override double Area() => Math.PI * Radius * Radius;
}
record also supports inheritance, but with one important restriction: a record can only inherit from another record, never from a plain class (and vice versa). Within that restriction, it behaves a lot like class inheritance, and it pairs beautifully with pattern matching for modelling a fixed set of related shapes — often called “algebraic data type” style modelling:
abstract record Shape;
record Circle(double Radius) : Shape;
record Rectangle(double Width, double Height) : Shape;
Shape s = new Circle(2.0);
var area = s switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle r => r.Width * r.Height,
_ => 0
};
struct and record struct, on the other hand, cannot inherit from anything (other than implementing interfaces) and are implicitly sealed. This isn’t a missing feature so much as a direct consequence of being a value type: inheritance relies on the idea that a Derived object can be substituted anywhere a Base reference is expected, which only makes sense when you’re dealing with references pointing at objects on the heap. Value types don’t have that kind of substitutability, so the language doesn’t offer struct inheritance at all. Both struct and record struct can still implement interfaces, though:
interface IShape { double Area(); }
record struct Circle(double Radius) : IShape
{
public double Area() => Math.PI * Radius * Radius;
}
Pattern Matching & Deconstruction
Because positional records generate a Deconstruct method automatically, you can immediately unpack them into separate variables, or use them in pattern-matching expressions, without writing any extra code:
record Point(int X, int Y);
var p = new Point(3, 4);
var (x, y) = p; // automatic deconstruction, thanks to the compiler-generated Deconstruct
Console.WriteLine($"{x},{y}"); // 3,4
if (p is (3, 4))
Console.WriteLine("Origin offset match!");
if (p is { X: > 0, Y: > 0 })
Console.WriteLine("In the first quadrant");
The same automatic deconstruction applies equally to record struct. A plain class or struct, by contrast, gets none of this unless you write a Deconstruct method yourself:
class PointClass
{
public int X, Y;
public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
}
Property patterns (the { X: > 0, Y: > 0 } syntax above) work on all four kinds of types — that part isn’t record-specific — but the tuple-style positional patterns and automatic var (x, y) = p; deconstruction specifically rely on the Deconstruct method that only records generate for you automatically.
Boxing — A Hidden Performance Trap for Value Types
“Boxing” is what happens when a value type gets wrapped up and placed on the heap so it can be treated as an object or through an interface reference. It’s easy to trigger without realising it:
struct PointStruct { public int X, Y; }
PointStruct p = new() { X = 1, Y = 2 };
object boxed = p; // BOXING happens here — a heap allocation is created
IComparable c = someComparableStruct; // also boxes, if the struct implements IComparable and is assigned through the interface
// The exact same thing can happen with record struct:
record struct PointRS(int X, int Y);
object boxedRs = new PointRS(1, 2); // also boxed
This matters because one of the main reasons people choose a value type in the first place is to avoid heap allocation — but if that value type frequently gets stored in a non-generic collection, assigned to an object variable, or passed through an interface, it quietly gets boxed anyway, and you lose the performance benefit you were trying to gain. class and record, being reference types already, never have this problem — assigning one to an object or interface variable is just copying a reference, with no extra allocation. This is one of the concrete reasons to think carefully before reaching for struct/record struct in code where the value will be stored generically or passed around as an interface a lot.
Performance Rule of Thumb: When Struct Copying Gets Expensive
Value types are copied every time they’re assigned, passed into a method, or returned from one. For a small struct like two ints, that copy is essentially free — a couple of machine words. But as a struct grows larger, that “free” copy stops being free:
// Cheap to copy — a reasonable struct candidate (16 bytes: two doubles)
readonly record struct Vector2D(double X, double Y);
// A poor struct candidate — copying this on every assignment, every method call,
// and every time it's added to a collection is expensive:
struct BigDataStruct
{
public Guid Id;
public string[] Tags; // this field is a reference, but the struct itself is still copied wholesale
public DateTime Created;
public decimal[] Amounts;
public byte[] Payload;
}
// -> this should be a `class` or `record` instead, so only a reference gets copied around
A commonly cited (though not strict) guideline is that once a value type grows past roughly 16 bytes, or gets copied very frequently, the copying overhead tends to outweigh the benefit of avoiding heap allocation, and you’re usually better off switching to a reference type (class/record).
Nullability
Because reference types are, at their core, just an address pointing at something, it’s entirely reasonable for that address to point at “nothing” — that’s exactly what null represents:
class PersonClass { }
record PersonRecord;
PersonClass? c = null; // fine
PersonRecord? r = null; // fine — record is a reference type under the hood
Value types don’t have this option, because there’s no “address” to leave empty — the variable directly is the data, and it must contain some value at all times:
struct PersonStruct { }
record struct PersonRecordStruct { }
PersonStruct s = null; // compile error, CS0037
PersonRecordStruct rs = null; // compile error, CS0037
PersonStruct? sNullable = null; // fine — wrapping in Nullable<T> adds an explicit "has no value" flag
PersonRecordStruct? rsNullable = null; // fine, same reasoning
PersonStruct? isn’t magic — it’s shorthand for Nullable<PersonStruct>, a small wrapper type that stores your value plus a separate boolean flag saying whether a value is actually present. This is a fundamentally different mechanism from reference-type null, even though the ? syntax looks identical in both cases.
Records vs. Record Structs — Revisiting the Immutability Difference
It’s worth returning to this point one more time, in its own section, because it’s the detail students most often get wrong on an exam or in a code review: a plain record’s positional properties are init-only, meaning they can only be assigned during construction and never again — so a record is immutable by default with no extra effort. A record struct’s positional properties, on the other hand, are ordinary mutable get; set; properties by default, so a record struct is not immutable unless you explicitly add the readonly modifier, either on the whole declaration (readonly record struct Point(int X, int Y);) or on individual members. Only once you’ve added readonly does a record struct become truly immutable and forbid reassigning its properties after construction, matching what record does automatically. If your goal is an immutable value object, the safe habit is to always write readonly record struct rather than relying on memory to add it later.
Choosing the Right Type — How to Decide
Start by asking yourself two questions about the type you’re designing, in order.
Question one: does this thing have an identity that matters independently of its data, and might it change over time? If you’re modelling a Customer, an Order, a background EmailService, or a DbContext, the answer is yes — two customers with the same name genuinely are two different customers, and an order’s status legitimately changes as it moves through your system. In that case, reach for a class. You want reference semantics (so everyone sharing the object sees the same updates) and you don’t need — and probably don’t want — structural equality baked in by default.
If instead the thing you’re modelling is really just data — where two instances containing the same values genuinely represent the same thing, like a Money amount, a coordinate, an event that happened, or a request/response payload — move to question two.
Question two: is this data small and copied frequently enough that avoiding heap allocation actually matters for performance, and do you not need inheritance? If yes — think coordinates, small math/geometry types, keys used heavily in a HashSet/Dictionary, currency values computed millions of times in a pricing loop — reach for readonly record struct. You get value-type performance (no GC pressure, independent copies) plus all the record conveniences (fast equality, readable ToString, with, deconstruction) with none of the reflection-based slowness a plain struct would give you by default.
If the answer to question two is no — the data is large, needs inheritance/polymorphism (for example, an abstract record Shape hierarchy), or will frequently be boxed/stored as object or through an interface — reach for a plain record instead. You keep all the same record conveniences, but as a reference type, so large payloads aren’t copied wholesale and boxing is never a concern.
Plain struct still has a place, but it’s a narrower one in modern C# (10 and later): reach for it specifically when you need low-level control that record struct doesn’t give you as cleanly — for example, precise interop layouts for P/Invoke via [StructLayout], or you’re intentionally avoiding the extra generated members for some specific reason. Absent one of those specific reasons, prefer readonly record struct over plain struct for new value types, since it gives you everything struct does plus fast equality, ToString, and with for free.
Full Syntax Reference
// ---- class ----
public class Car
{
public string Model { get; set; }
public Car(string model) => Model = model;
}
// ---- struct ----
public struct Car
{
public string Model { get; set; }
public Car(string model) => Model = model;
}
public readonly struct ImmutableCar
{
public string Model { get; }
public ImmutableCar(string model) => Model = model;
}
// ---- record (class) ----
public record Car(string Model); // positional, immutable, concise
public record class Car(string Model); // identical, explicit "class" keyword
public record Car { public string Model { get; init; } } // non-positional form
// ---- record struct ----
public record struct Car(string Model); // mutable properties by default!
public readonly record struct Car(string Model); // fully immutable — usually what you want
// ---- inheritance example (records only) ----
public abstract record Shape;
public sealed record Circle(double Radius) : Shape;
public sealed record Square(double Side) : Shape;
When and Why Use class
Use it when the type represents something with identity, lifecycle, or behaviour — not just a bundle of data.
Reach for class whenever identity matters more than value. Two Customer objects with the same name and email are still two different customers in the real world — reference equality is the correct semantic here, not a limitation to work around:
class Customer { public string Name; public string Email; }
var c1 = new Customer { Name = "Alice", Email = "a@x.com" };
var c2 = new Customer { Name = "Alice", Email = "a@x.com" };
// c1 and c2 are two distinct customer records in your system, even though the data matches.
// Reference equality (c1 == c2 is false) is exactly what you want here.
class is also the natural choice whenever a type has mutable state that changes over time — services, controllers, repositories, caches, connections, and anything else that gets updated in place (order.Status = OrderStatus.Shipped;). It’s the right tool whenever inheritance and polymorphism are core to the design: class hierarchies with virtual/override, abstract base classes, template-method patterns, and dependency-injected services implementing interfaces all rely on class semantics. It also fits large or complex object graphs well, since passing these around by reference avoids expensive copying, and lets multiple parts of the code share and mutate one instance (a DbContext, a Logger, a UI ViewModel). Finally, class is a reasonable default whenever you simply don’t need structural equality, or are willing to hand-roll Equals/GetHashCode for the rare cases where you do.
Typical uses: domain entities with a persistent identity (User, Order, Account), services (EmailService, PaymentGateway), ASP.NET Core controllers, ViewModels, repositories, anything wired through DI containers.
Avoid it when the type is just a small, interchangeable data value where two instances with equal data really are “the same thing” — that’s what record/struct are for.
When and Why Use record
Use it when the type represents an immutable value or a piece of data whose identity IS its content.
The clearest signal that you want a record is when value equality is the natural semantic for the thing you’re modelling. A Money(100, "USD") is the same value as another Money(100, "USD") — there’s no meaningful sense in which they’re “different objects”:
record Money(decimal Amount, string Currency);
var price1 = new Money(9.99m, "USD");
var price2 = new Money(9.99m, "USD");
Console.WriteLine(price1 == price2); // True — exactly the semantic you want
Immutability by default is another major reason to reach for record: because a record’s positional properties can’t be silently changed elsewhere in the code, you get compiler-enforced protection against a whole class of aliasing bugs, plus thread-safety for free, since immutable objects are inherently safe to share across threads without locking. On top of that, with-expressions make transformations concise and safe, replacing manual copy-constructor boilerplate with a single clear expression:
record OrderLine(string Sku, int Qty, decimal Price);
var line = new OrderLine("ABC123", 2, 19.99m);
var updated = line with { Qty = 3 }; // clear intent, no manual copy-constructor boilerplate
Records also give you a genuinely useful ToString() and equality out of the box, which pays off enormously in logging, debugging, and testing — Assert.Equal(expected, actual) on records “just works” structurally, with no need to compare field-by-field or override Equals in test fixtures. That combination makes record a natural fit for DTOs, API request/response models, events, and messages — anything that’s really “data plus identity-by-value,” where a UserCreatedEvent(Guid UserId, string Email) published twice with identical values should be treated as equal. Finally, records pair beautifully with pattern matching for algebraic-data-type-style modeling: an abstract record Shape with sealed record Circle/Rectangle subclasses lets you branch exhaustively and type-safely with a switch expression.
Typical uses: DTOs, API contracts, CQRS commands/queries, domain events, configuration snapshots, value objects in DDD (Address, Money, DateRange), discriminated-union-style modeling.
Avoid it when you need mutable, long-lived state, or your object has a distinct identity that persists independent of its field values — use class for those instead.
When and Why Use struct
Use it when you need a small, simple, performance-sensitive value type and don’t need any of the modern conveniences records add — or you’re targeting an older language version.
The most common reason to reach for struct is that avoiding heap allocation and GC pressure genuinely matters. In hot paths — tight loops, high-throughput services, game loops, numerical code — allocating thousands of small objects per second creates GC pauses that can hurt performance noticeably. A struct lives on the stack (or inline in an array or containing object) and simply doesn’t generate garbage the way repeatedly allocating small classes would:
struct Vector3 { public float X, Y, Z; }
Vector3[] particles = new Vector3[1_000_000]; // one contiguous block, no per-element heap allocation
struct is also the right choice when value copy semantics are exactly what you want: passing a struct to a method automatically gives that method its own independent copy, so there’s no risk of the callee mutating your data unexpectedly unless you explicitly pass it by ref. It’s essentially required for interop with unmanaged code and fixed memory layouts — struct supports [StructLayout], is necessary for P/Invoke signatures matching C structs, and works naturally with Span<T>/stackalloc scenarios. And of course, if you’re working in an older C# version (pre-C# 10) where record struct doesn’t exist yet, plain struct is your only option for value semantics. Finally, struct is a reasonable choice if you need default equality but don’t care about the reflection-based performance cost, or you’re planning to override Equals/GetHashCode/IEquatable<T> yourself anyway.
Typical uses: DateTime, Guid, TimeSpan-style primitives, math/graphics types (Vector2, Matrix4x4), pixel/colour structs, small fixed-size buffers, P/Invoke interop structs, enum-like value wrappers.
Avoid it when the type is large (rule of thumb: bigger than roughly 16 bytes), gets boxed frequently, needs inheritance, or you’d benefit from the auto-generated ToString/equality/with support that record struct provides. In nearly all new code targeting C# 10 or later, prefer record struct over a plain struct unless you have a specific reason not to, such as needing full manual control over generated members or interop attributes that conflict with the record struct’s generated code.
When and Why Use record struct
Use it when you want everything a struct gives you (stack allocation, value/copy semantics, no GC pressure) plus everything a record gives you (fast generated equality, readable ToString, with-expressions, deconstruction) — which is most of the time you’d reach for a struct in modern C#.
The strongest case for record struct is small immutable value objects that get compared or hashed a lot. Coordinates, money amounts, IDs, RGB colors, ranges — anything you’ll put in a HashSet<T> or use as a Dictionary<TKey, TValue> key benefits enormously from the compiler-generated, allocation-free Equals/GetHashCode:
readonly record struct Point(int X, int Y);
var visited = new HashSet<Point>();
visited.Add(new Point(1, 2));
visited.Contains(new Point(1, 2)); // True, fast, no boxing, no reflection
More broadly, record struct is the right choice whenever you want the ergonomics of records — concise positional syntax, with, ToString, deconstruction — without paying for heap allocation. This is the main reason record struct was added to the language in C# 10: plain struct never got these features, and hand-writing them for every small value type was tedious and error-prone. It’s an especially good fit for high-frequency value objects in performance-sensitive code that still benefit from readability — for example, a readonly record struct Money(decimal Amount, string Currency) used millions of times in a pricing engine gets no GC churn, but still keeps with, equality, and nice debug output.
The one thing to always keep in mind is the mutability gotcha covered earlier: pair record struct with readonly (readonly record struct) almost every time, unless you specifically want in-place field mutation — for example, a mutable accumulator struct updated in a tight loop to avoid allocating a new struct every iteration:
// Deliberately mutable — used as a scratch accumulator in a hot loop
record struct RunningTotal(decimal Sum, int Count)
{
public void Add(decimal value) { Sum += value; Count++; }
}
Typical uses: geometry/math value types (Point, Vector2, Rect), currency/measurement value objects, small immutable keys for collections, tuples-with-names replacing (int, int) value tuples when you want named, equality-friendly semantics, hot-path DTOs in performance-critical services.
Avoid it when the type is large, needs inheritance/polymorphism, or is frequently boxed or stored as object/interface — those scenarios favour record (a reference type) instead.
Summary
If you remember nothing else from this guide, remember this: class and record are reference types that live on the heap and get copied by reference; struct and record struct are value types that live inline and get copied by value. The word “record” — on either kind — means “the compiler will generate fast structural equality, a readable ToString(), and with-expression support for you.” record is immutable by default because its positional properties are init-only; record struct is not immutable by default, because its positional properties are ordinary mutable properties — so write readonly record struct when you want a truly immutable value type, which is most of the time. Choose class when identity and mutable state matter; choose record when a piece of data’s value is its identity and you don’t need to avoid heap allocation; choose struct/record struct when the data is small, copied frequently, and you want to avoid GC pressure — and in modern C#, default to readonly record struct over plain struct unless you have a specific reason not to.











