Value Types vs Reference Types
Why int lives on the stack and string doesn't β and why that distinction costs you when you ignore it.
In .NET, every type is either a value type (struct) or a reference type (class). Value types store their data inline β on the stack or inside another object. Reference types always live on the heap, and variables hold a pointer. This distinction drives performance characteristics, copy semantics, and GC pressure in ways that are invisible until they bite you. C# 13 continues expanding the power of value types with ref structs, Span<T>, and inline arrays.
Value types (struct, int, bool, DateTime) are allocated inline β on the stack for local variables, or inline within the containing object on the heap. Reference types (class, string, arrays) always allocate on the heap; the variable holds a 8-byte pointer. Stack allocations are 'free' β just moving the stack pointer.
Assigning a value type copies all its fields. a = b makes a fully independent copy. There's no aliasing β mutating a doesn't affect b. This is why passing a DateTime to a method is safe without defensive copying β the callee gets its own copy.
Assigning a reference type copies the pointer. a = b means both variables point to the same heap object. Mutating through a also mutates through b. This is why List<T> parameters don't need ref β the callee and caller share the same object.
Boxing is the implicit operation that wraps a value type in a heap-allocated object so it can be treated as object or an interface. It allocates memory, copies the struct fields into the heap object, and returns a reference. This is why adding an int to an ArrayList allocates β and why Span<T> exists to avoid it.
ref struct types (.NET Core 2.1+) are stack-only β they cannot be boxed, cannot be heap-allocated, cannot be captured in closures, and cannot implement interfaces. Span<T> is the canonical ref struct. This constraint enables the JIT to make hard guarantees about their lifetime, enabling zero-allocation slice/pipe APIs.
Key Concepts
Value types in local scope are allocated by advancing the stack pointer. Freed instantly when the method returns β no GC involvement. Arrays and objects allocated via 'new' still go to the heap even if their type is a struct.
Implicit operation that wraps a value type in a heap-allocated object. Triggered by: casting struct to object/interface, putting struct in non-generic collection, using struct as dynamic. Each box is a new allocation.
Stack-only struct. Cannot be boxed, stored in fields of regular classes, captured in lambdas, or used with async/await. Enables safe zero-copy slicing. Span<T>, ReadOnlySpan<T>, and Memory<T> are all ref structs.
A ref struct representing a contiguous region of memory β stack, heap, or unmanaged. Zero-copy slicing of arrays, strings, and unmanaged buffers. .NET 9 expanded Span APIs to cover most BCL string/array operations.
Pass-by-readonly-reference. Avoids copying large structs when passing to methods. TRAP: non-readonly struct methods called through 'in' parameters can trigger defensive copies, making 'in' slower than value copy for small structs.
Value types compare by content (Equals compares fields). Reference types compare by identity by default (Equals compares pointer). Records give you value semantics on reference types with = = comparing property values.
1// C# 13 β The struct mutation trap that bites everyone2// β οΈ this bites everyone eventually34public struct MutablePoint5{6 public int X;7 public int Y;8 public void Translate(int dx, int dy) { X += dx; Y += dy; }9}1011// DON'T do this β IEnumerable<T> boxes the struct12// Translate() is called on the COPY inside the box, not the original13IList<MutablePoint> points = new List<MutablePoint> { new(1, 1) };14((MutablePoint)points[0]).Translate(10, 10); // Compiles! Does nothing.1516// The REAL boxing trap β interface call on struct17IMutatable shape = new MutablePoint { X = 5, Y = 5 }; // BOXES here18shape.Translate(10, 10); // operates on the heap copy19Console.WriteLine(((MutablePoint)shape).X); // still 15 β fine here20// But if you stored the original struct separately, it's unchanged2122// C# 13 ref struct with Span<T> β zero allocation, stack-only23public ref struct StackOnlyBuffer24{25 private Span<byte> _buffer;2627 public StackOnlyBuffer(Span<byte> buffer) => _buffer = buffer;2829 public void Write(ReadOnlySpan<byte> data)30 => data.CopyTo(_buffer);31}3233// Usage β entire pipeline stack-allocated, zero GC pressure34void ProcessRequest(ReadOnlySpan<byte> rawBytes)35{36 Span<byte> workspace = stackalloc byte[512]; // stack allocation37 var buf = new StackOnlyBuffer(workspace);38 buf.Write(rawBytes);39 // No heap allocation, no GC, ref struct can't escape to heap40}4142// 'in' parameter gotcha β defensive copy bites you silently43public struct LargeMatrix44{45 public double[,] Data; // reference inside value type46 public int Rows, Cols;4748 // β οΈ 'in' parameters for readonly methods are a lie for non-readonly structs49 // The JIT may emit a defensive copy before the method call50 public double GetDiagonalSum() { /* ... */ return 0; }51}5253void ProcessMatrix(in LargeMatrix matrix)54{55 // If LargeMatrix isn't fully readonly, the JIT copies it to satisfy 'in'56 // Defeating the entire purpose of 'in' for performance57 var sum = matrix.GetDiagonalSum(); // potential defensive copy here58}
Value type design choices ripple through entire systems. A struct that gets boxed 50,000 times per second is a silent GC tax. Span<T> and Memory<T> unlocked a new era of high-performance .NET code β the BCL itself was rewritten to avoid allocations using these APIs. Understanding boxing, stack allocation, and ref structs lets you write code that performs like C++ while keeping C# safety guarantees.
Common Pitfalls
1The Invisible Struct Mutation Bug
Our game engine used a mutable Vector3 struct for all physics calculations. A junior dev refactored the entity update loop to use a List<IMovable> interface for polymorphism. After the refactor, physics objects stopped responding to velocity changes β but only intermittently, and only when the entity count exceeded 50.
The struct was boxed when stored through the IMovable interface. Every call to IMovable.ApplyVelocity() operated on the heap copy inside the box. The original struct stored in the physics array was never updated. The bug was intermittent because small entity counts hit a code path that used a different data structure that didn't box.
We rewrote IMovable as a generic interface IMovable<TSelf> where TSelf : struct, IMovable<TSelf> and used generic constraints to avoid boxing. For the hot path we switched to a struct-of-arrays layout (separate float[] for X, Y, Z) and Span<float> slices β zero allocation, SIMD-friendly, no interface dispatch.
Takeaway: Calling a method through an interface on a struct boxes it. The method operates on the copy in the box. Your original struct is unchanged. Use generic constraints, virtual dispatch on classes, or source generators to avoid unintentional boxing in hot paths.
2ArrayPool Saves a Payment Processor
Our payment gateway processed 50,000 ISO 8583 messages per second during Black Friday. After migrating to .NET 8 we profiled the GC and found 40% of all allocations were byte[] buffers between 256B and 4KB β created per message, collected almost immediately. Gen0 GC paused us 200+ times per second.
Each message parse allocated a new byte[] for the working buffer, processed it, then discarded it. These tiny short-lived allocations were exactly the Gen0 pressure scenario that kills throughput. 50K * 4KB * 200 GC pauses/s was measurable latency jitter even though each pause was only 0.5ms.
We replaced new byte[bufferSize] with ArrayPool<byte>.Shared.Rent(bufferSize) and returned buffers in a finally block. Combined with Span<byte> slices for parsing (zero-copy sub-buffer access) and stackalloc for buffers under 256 bytes. Gen0 collections dropped 95%, p99 latency fell from 12ms to 3ms.
Takeaway: ArrayPool<T>.Shared is the single highest-ROI performance change for services that allocate many short-lived arrays. Pair it with Span<T> for zero-copy slicing. The pattern is: Rent β use as Span β Return in finally. Never forget the Return or you'll leak pool slots.