Value types versus reference types in .NET 10: what each kind is, copy and assignment semantics, default equality, real storage locations, object overhead, and how to choose.
A value type holds its data directly, so assigning or passing it copies the data; a reference type variable holds a reference to an object on the managed heap, so copying the variable makes two references to the same object. Value types are stored inline where they are declared: a local usually sits on the stack or in a register, but a struct field of a class lives inside that object on the heap, and captured or boxed values also end up on the heap. By default classes compare by identity and structs by their fields. I use structs for small, immutable values like Money or coordinates, and classes for everything with identity or behaviour.
The real difference is semantics, not location: a value-type variable contains the data, a reference-type variable contains a reference to an object on the managed heap. Everything else — copying, equality, nullability, where memory lives — follows from that.
| Value types | Reference types |
|---|---|
structs: int, double, decimal, bool, char, DateTime, Guid, TimeSpan, Span<T> |
classes: string, arrays, List<T>, your services and entities |
enums |
interface types (the variable holds a reference) |
record struct, tuples (ValueTuple) |
record (class), delegates, object, dynamic |
Derive from System.ValueType |
Derive from System.Object directly or indirectly |
var p1 = new Point(1, 2); // struct
var p2 = p1; // copies the data
p2.X = 99; // p1.X is still 1
var c1 = new Customer { Name = "Ada" }; // class
var c2 = c1; // copies the reference
c2.Name = "Grace"; // c1.Name is now "Grace" - same object
list[0].X = 5 does not compile for that reason; foreach (var p in points) p.X = 5; mutates nothing useful).Default Equals |
Default == |
|
|---|---|---|
| Class | Reference equality | Reference equality (not defined unless overloaded) |
| Struct | Field-by-field value equality via ValueType.Equals (reflection-based unless fields are "tightly packed" bit-comparable) |
Not defined for user structs unless you overload it |
record / record struct |
Compiler-generated value equality | Generated == |
string |
Value (ordinal) equality | Overloaded to value equality |
The default struct Equals can be very slow (reflection, boxing) — always implement IEquatable<T> on structs used as dictionary keys, or use a record struct.
The correct statement is: value types live wherever their containing variable lives.
int[] live in the array on the heap.await, or inside an iterator is hoisted into a heap-allocated closure or state machine.static struct field lives in the type's static storage (on the heap in .NET).Conversely, reference-type objects almost always live on the heap, while the reference variable itself may be on the stack. (.NET 9/10's JIT can even stack-allocate some small objects and arrays that provably do not escape, via escape analysis — so "objects are always on the heap" is also becoming only mostly true.)
The only value types guaranteed to stay on the stack are ref structs like Span<T> — the compiler forbids them from being boxed, captured or stored in heap fields.
null; their default is all-zero bits (0, false, DateTime.MinValue, Guid.Empty). Nullable<T> (int?) adds a HasValue flag.null. Nullable reference types (string?) are a compile-time annotation only.Microsoft's guidance, which I follow: a struct should represent a single small value, be immutable (readonly struct), be small (≈16 bytes or less is ideal; beyond ~64 bytes copying costs add up), and not be boxed frequently. Good examples: Money, Coordinate, strongly-typed IDs (readonly record struct OrderId(Guid Value)). Everything with identity, a lifecycle or behaviour-heavy logic should be a class.
A telemetry pipeline stored millions of readings as a class Reading { DateTime At; double Value; }. Each object carried a 16-byte header plus padding, and the GC spent a lot of time tracing millions of small objects. Changing it to a readonly record struct Reading(DateTime At, double Value) stored in arrays packed the data contiguously (16 bytes per reading instead of ~40 including the reference), cut managed heap size substantially, and reduced Gen2 GC pause time. The opposite mistake also happens: a 200-byte mutable struct passed around by value caused both slow copies and "lost update" bugs, and was correctly turned back into a class.
A foundational question that separates memorised slogans from real understanding.
Green flags
ValueType.Equals.Red flags
Every reference-type object on 64-bit .NET has:
[ object header (8 bytes) ][ MethodTable pointer (8 bytes) ][ fields ... ]
^ sync block index / hash code / lock info
A struct has no header and no MethodTable pointer when stored inline — just its fields (plus padding for alignment). That is why an array of 1,000,000 ints is ~4 MB, while 1,000,000 boxed ints are ~24 MB plus 8 MB of references.
When the JIT compiles a method, value-type locals are assigned to registers where possible, otherwise to slots in the method's stack frame. Struct locals may be promoted (each field gets its own register — "struct promotion") if the struct is small and not address-exposed. Taking a ref to a struct, passing it by ref, or calling non-inlined methods on it can prevent promotion, so it lands in memory.
Reference-type locals are just pointer-sized values in registers or stack slots; the GC scans them as roots at safe points to find live objects.
The C# specification only defines semantics: value types have copy semantics, reference types have reference semantics. Where memory lives is a runtime choice:
async methods move locals into state machine structs that are boxed to the heap on the first real suspension.yield iterators move locals into iterator classes.Eric Lippert's well-known point: describing value types as "stack types" teaches the wrong mental model. Teach semantics first.
ref struct — the true stack-only typesSpan<T>, ReadOnlySpan<T>, Utf8JsonReader and your own ref structs must live on the stack because they may contain managed pointers (ref fields) into stack memory or the middle of objects. The compiler enforces:
object or interfaces (C# 13 allows implementing interfaces and generic allows ref struct constraints, but still no boxing).ref struct type inside classes or normal structs.await or yield (relaxed in C# 13 when not crossing the suspension point).ValueType.Equals(object):
ValueType.GetHashCode historically hashed only the first non-null field, which can produce terrible hash distribution in dictionaries (e.g. a struct whose first field is a constant). That is why structs used as keys should implement IEquatable<T> and GetHashCode with HashCode.Combine, or be record structs, which generate both.
Copying a struct is a memcpy of its size. For small structs this is cheaper than a heap allocation plus GC. For large structs, copies on every assignment, argument and return add up, and the compiler creates defensive copies when calling non-readonly members on readonly locations (readonly fields, in parameters). Marking the struct readonly removes them.
Point[] (struct): [X][Y][X][Y][X][Y]... contiguous, cache-friendly
Point[] (class): [ref][ref][ref]... -> objects scattered across the heap
Iterating an array of structs reads sequential memory, which CPU prefetchers love; iterating references causes a potential cache miss per element. This is the core of data-oriented design and why high-performance .NET code (game engines, parsers, ML.NET) uses structs in arrays and spans.
ObjectLayoutInspector (NuGet) to print the layout of a small class and the equivalent struct.GC.GetAllocatedBytesForCurrentThread() before and after creating 1,000,000 class instances vs filling a struct array.IEquatable<T>.foreach and inside a List<T> via the indexer to see copy semantics bite.namespace Types;
public readonly record struct GeoPoint(double Lat, double Lon); // value type, value equality
public sealed class Order { public decimal Total { get; set; } } // reference type
public struct MutablePoint { public int X; public int Y; }
public static class ValueVsReference
{
public static void Main()
{
// Copy semantics
var a = new MutablePoint { X = 1, Y = 2 };
var b = a; // copy
b.X = 99;
Console.WriteLine(a.X); // 1
var o1 = new Order { Total = 10m };
var o2 = o1; // same object
o2.Total = 99m;
Console.WriteLine(o1.Total); // 99
// Equality
Console.WriteLine(new GeoPoint(1, 2) == new GeoPoint(1, 2)); // True (record struct)
Console.WriteLine(new Order() == new Order()); // False (identity)
// Struct inside an array is stored inline: modify through the array element
var points = new MutablePoint[3];
points[0].X = 5; // modifies the element in place
var list = new List<MutablePoint> { new() };
// list[0].X = 5; // CS1612: list[0] returns a copy
var copy = list[0]; copy.X = 5; list[0] = copy;
// Passing to a method copies the struct, not the class
Bump(a); BumpOrder(o1);
Console.WriteLine($"{a.X} {o1.Total}"); // 1 100
}
private static void Bump(MutablePoint p) => p.X++;
private static void BumpOrder(Order o) => o.Total++;
}
What happens: candidates claim int fields of a class are on the stack, or that structs never cause GC work.
Why it is wrong: value types live where their container lives — inside heap objects, arrays, closures and async state machines.
Do instead: explain copy semantics first, then location as an implementation detail; mention ref struct as the only stack-only guarantee.
What happens: struct Counter { public int Value; } modified through collections, properties or foreach.
Why it is wrong: you mutate copies, so updates are silently lost (list[0].Value++ does not compile; GetCounter().Value++ changes a temporary).
Do instead: make structs readonly and return new values (with expressions on record structs).
What happens: a 100+ byte struct is used for DTOs or domain objects "to avoid GC".
Why it is wrong: every assignment, argument and return copies all the bytes, often costing more than the allocation it saved.
Do instead: keep structs small (≈16 bytes ideal); use classes for larger data, or pass large readonly structs by in after benchmarking.
What happens: a custom struct key relies on ValueType.Equals and GetHashCode.
Why it is wrong: reflection-based equality is slow and boxes, and the default hash can collide badly.
Do instead: implement IEquatable<T> and GetHashCode with HashCode.Combine, or use a readonly record struct.
What happens: var copy = original; copy.Status = Cancelled; expecting original to be untouched.
Why it is wrong: both variables reference the same object.
Do instead: clone explicitly or use records with with expressions for non-destructive mutation.
== expecting value equalityWhat happens: two Customer objects with the same data compare unequal.
Why it is wrong: classes use reference equality unless you override Equals/==.
Do instead: use records for data-centric types, or implement equality deliberately (and consistently with GetHashCode).
What happens: entities or services are converted to structs to reduce allocations.
Why it is wrong: structs boxed into interfaces, object or non-generic collections allocate anyway, and copying can dominate.
Do instead: measure with BenchmarkDotNet and a memory profiler; use structs for small, immutable values with clear value semantics.
What happens: default(OrderId) or an uninitialised struct field is treated as valid.
Why it is wrong: structs can always be zero-initialised, bypassing constructors — e.g. an OrderId with Guid.Empty.
Do instead: validate at boundaries, and consider whether default is a meaningful value for the type.
What happens: structs stored in object, ArrayList, non-generic interfaces or passed to string.Format with object parameters.
Why it is wrong: each box is a heap allocation, defeating the purpose of a struct.
Do instead: use generics, IEquatable<T>, and interpolated string handlers that avoid boxing.
What happens: assuming DateTime can be null in a DTO, or that a missing JSON value will become null.
Why it is wrong: value types are never null; missing values deserialise to default (e.g. 0001-01-01), which can be written to the database as a real date.
Do instead: use DateTime?/DateOnly? when absence is meaningful, and mark required properties with required.
Seniors frame this as semantics first, performance second, and know when the trade-off flips.
| Concern | Struct wins | Class wins |
|---|---|---|
| Millions of small items in arrays | inline storage, no per-object overhead, cache locality | |
| Data passed around a lot and large | one reference copied, not all bytes | |
| Identity, lifecycle, polymorphism | yes | |
Used through interfaces or object |
structs box | |
| Thread safety | immutable structs are trivially safe to copy | shared mutable objects need locks |
[MemoryDiagnoser], and check GC behaviour with dotnet-counters, rather than guessing.Money, Percentage, PostCode) as readonly record struct remove primitive obsession at almost no cost.The stack is a per-thread region that holds method frames: locals, arguments, return addresses and saved registers. Allocating is just moving the stack pointer, and everything is freed when the method returns. The heap is shared memory for objects whose lifetime is not tied to one method; the generational garbage collector frees them once they are unreachable.
The common line "value types live on the stack, reference types on the heap" is only half true. Storage follows the declaration: a value-type local usually lives on the stack (or in a register), but a value-type field lives inside its object on the heap, and boxing, lambdas that capture locals, iterators and async methods move data to the heap. Reference-type objects are conceptually heap objects (only the reference sits on the stack), although the current JIT's escape analysis can place a small object that provably never leaves the method on the stack as an optimisation. The language guarantees lifetime and copy semantics, not a location.
Boxing wraps a value type in a new heap object so it can be used as object or an interface; unboxing checks the object's type and copies the value back, and it must be the exact type or you get InvalidCastException. Every box is a heap allocation, so in hot paths it creates GC pressure. Hidden boxing comes from non-generic APIs like ArrayList and params object[], passing structs as interfaces, calling GetType() or non-overridden Equals/GetHashCode on structs, and enumerating a List<T> through IEnumerable<T>. I find it with BenchmarkDotNet's MemoryDiagnoser or an allocation profiler and fix it with generics, constraints and IEquatable<T>.
System.Object gives every type Equals, GetHashCode, ToString, GetType, the protected MemberwiseClone and Finalize, plus static ReferenceEquals. The review problem is the Equals/GetHashCode contract: equal objects must return equal hash codes. If Equals compares Email but GetHashCode is still identity-based, two "equal" customers land in different hash buckets, so Dictionary, HashSet, Distinct and GroupBy treat them as different. I'd implement IEquatable<Customer>, override both methods using the same immutable fields, compare emails case-insensitively in both, overload ==/!=, and seal the class. For value-like data a record generates all of this.
Last updated 30 Oct 2026.