Why value vs reference types trips people up in interviews
Every C# developer knows the words "value type" and "reference type," but the
interviews probe understanding rather than vocabulary. When does a value type live
on the heap? What actually happens when you box an int? Why does passing a List<T>
to a method let the method mutate it but not replace it? What is a ref struct and
why can't you put Span<T> in an async method? These are the questions that
distinguish developers who use C# from those who understand its object model.
The fundamental distinction — copy semantics vs shared identity
Value types carry their data directly. Assigning them copies the data. Reference types store a reference (pointer) to data on the heap. Assigning them copies the reference — both variables now point to the same object.
// Value type: copy on assign
int a = 10;
int b = a; // b is an independent copy
b = 99;
Console.WriteLine(a); // 10 — unchanged
// Reference type: shared reference on assign
var list1 = new List<int> { 1, 2, 3 };
var list2 = list1; // list2 points to the SAME list object
list2.Add(4);
Console.WriteLine(list1.Count); // 4 — list1 sees the change
This single distinction — copy vs shared — explains most of the behaviour differences that come up in interviews.
Where are they stored? The real answer
The textbook answer — "value types on the stack, reference types on the heap" — is an oversimplification that causes confusion. The correct answer is: location depends on context, not type category.
| Declared as | Where the data lives |
|---|---|
| Local variable of a value type | Stack (method frame) |
| Local variable of a reference type | Stack holds the reference; object is on the heap |
| Field of a class (value type) | Heap — embedded inside the class object |
| Field of a class (reference type) | Heap — reference stored in the class object; target also on heap |
| Field of a struct (any type) | Wherever the struct lives (stack or heap) |
| Array element | Heap — array is always a reference type; elements are embedded |
struct Point { public int X, Y; } // value type
class Circle
{
public Point Center; // Point is a value type, but it lives on the heap
} // inside the Circle object
void Method()
{
Point local = new Point { X = 1, Y = 2 }; // on the stack — local var
Circle c = new Circle(); // Circle object on the heap
c.Center.X = 5; // modifying heap-embedded Point
}
Rule of thumb: Ask "what is the container?" not "what is the type?" If the container is a class object, its fields are on the heap regardless of their type. If the container is a method frame, local value types are on the stack.
Boxing and unboxing — the hidden performance tax
Boxing copies a value type into a heap-allocated System.Object wrapper.
Unboxing extracts it back. Both are implicit in the C# syntax but have real costs.
int x = 42;
object boxed = x; // BOXING: heap allocation + copy of x's data
int unboxed = (int)boxed; // UNBOXING: type check + copy back
Boxing costs:
- A heap allocation — the GC must find space on the managed heap.
- A memory copy — the value's bytes are copied into the new heap object.
- GC pressure — every boxed value is a small heap object the GC must eventually collect.
The most common source of hidden boxing in older .NET code is non-generic collections:
// ArrayList — every Add boxes value types:
var al = new ArrayList();
al.Add(42); // boxes int → object
al.Add(true); // boxes bool → object
int v = (int)al[0]; // unboxes + cast
// Fix: use generic List<T> — no boxing:
var list = new List<int>();
list.Add(42); // int stored directly as int
Other boxing traps: passing value types to methods that accept object, string
interpolation with $"{someStruct}" (though this is fixed for common types in modern .NET),
and interface calls on unboxed value types (calling an interface method on a struct
boxes the struct first, unless the reference is typed as T where T : IInterface).
Struct vs class — when to choose which
Both struct and class can have fields, properties, methods, and implement
interfaces. The key differences:
| Concern | struct | class |
|---|---|---|
| Type category | Value type | Reference type |
| Default value | Zero-initialised (no null) | null |
| Inheritance | Cannot inherit from another struct/class; can implement interfaces | Full inheritance hierarchy |
new in local scope | Stack-allocated (no GC pressure) | Heap-allocated |
| Equality default | Structural (member-wise via ValueType.Equals) | Identity (same object) |
| Nullable | Point? is Nullable<Point> | All reference types are already nullable |
// Good struct: small, immutable, value semantics
readonly struct Vector2
{
public readonly float X, Y;
public Vector2(float x, float y) => (X, Y) = (x, y);
public float Length => MathF.Sqrt(X * X + Y * Y);
public static Vector2 operator +(Vector2 a, Vector2 b) => new(a.X + b.X, a.Y + b.Y);
}
// Bad struct candidate: too large, mutable, identity matters
// struct Customer { string Name; List<Order> Orders; /* ... lots more */ }
// → should be a class
The .NET framework's guidelines: prefer struct for types smaller than ~16 bytes, that
are logically a single value (like a number or coordinate), are immutable, and are not
frequently boxed. int, DateTime, Guid, System.Numerics.Vector3 all meet these
criteria.
Mutable structs are a common source of bugs because assignments copy silently:
struct Mutable { public int X; }
Mutable m = new Mutable { X = 1 };
var list = new List<Mutable> { m };
list[0].X = 99; // COMPILE ERROR — indexer returns a copy; you'd modify the copy
// Fix: use a class, or replace the entire struct: list[0] = new Mutable { X = 99 };
Reference parameters — ref, out, in
By default C# passes all arguments by value — a copy of the argument. For value types, that's the data. For reference types, that's the reference. The three modifiers change this behaviour.
ref — read/write alias
void Increment(ref int n) => n++;
int x = 5;
Increment(ref x); // must use 'ref' at call site
Console.WriteLine(x); // 6 — x was modified through the alias
ref is useful for avoiding large struct copies in performance-sensitive code and for
methods that need to replace the caller's reference variable.
out — initialise before returning
bool TryParse(string s, out int result)
{
result = 0; // must assign before returning true or false
return int.TryParse(s, out result);
}
if (TryParse("42", out int n)) // n declared inline (C# 7+)
Console.WriteLine(n); // 42
out is the standard pattern for multi-return values and Try-style methods. The
variable need not be initialised before the call, but the method must assign it before
returning.
in — read-only reference
readonly struct BigMatrix { /* 64 bytes */ }
// Without 'in': 64-byte copy on every call
void Compute(BigMatrix m) { /* ... */ }
// With 'in': reference, no copy, callee cannot mutate
void Compute(in BigMatrix m) { /* ... */ }
in is an optimisation hint for large readonly structs. The compiler enforces that
the callee cannot write to m. For small structs (≤pointer size), in may actually be
slower because dereference overhead exceeds copy cost — benchmark first.
ref struct and Span<T>
A ref struct is a struct that is forced to live on the stack. The compiler
prevents boxing, storing as a class field, or using it across await or lambda
boundaries. This constraint enables a powerful guarantee: a ref struct will always
outlive its use within the same stack frame.
The primary ref struct in the BCL is Span<T> — a type-safe, bounds-checked view
over contiguous memory that can point to:
- A managed array segment
- Stack-allocated memory (
stackalloc) - Native (unmanaged) memory
// Slice an array without allocating a new array:
byte[] data = GetData();
Span<byte> header = data.AsSpan(0, 8); // zero-copy view of first 8 bytes
// Parse a substring without allocating a string:
ReadOnlySpan<char> dateStr = "2026-06-22";
int year = int.Parse(dateStr.Slice(0, 4)); // "2026" — no heap allocation
// Stack-allocated buffer — zero GC pressure:
Span<byte> buffer = stackalloc byte[512];
FillBuffer(buffer);
The stack-only constraint is why Span<T> cannot be used in async methods or stored
in class fields. When you need a heap-storable equivalent, use Memory<T>.
// Memory<T> — heap-storable alternative:
private Memory<byte> _buffer = new byte[512]; // can be a class field, used across awaits
async Task ProcessAsync()
{
var slice = _buffer.Slice(0, 10);
await SomeAsyncOperation(slice); // fine — Memory<T> is not a ref struct
Span<byte> span = slice.Span; // get Span<T> only when in a synchronous context
}
Equality semantics
Default equality differs between value types and reference types:
// Value type (struct): structural equality by default (member-wise)
var d1 = new DateTime(2026, 1, 1);
var d2 = new DateTime(2026, 1, 1);
Console.WriteLine(d1 == d2); // True — same data
// Reference type (class): identity equality by default
var p1 = new Person { Name = "Alice" };
var p2 = new Person { Name = "Alice" };
Console.WriteLine(p1 == p2); // False — different objects
Console.WriteLine(p1.Equals(p2)); // False — unless Equals() overridden
To give a class value-like equality, override Equals() and GetHashCode(), and
optionally overload ==. C# 9 records automate all of this:
record Person(string Name, int Age);
var a = new Person("Alice", 30);
var b = new Person("Alice", 30);
Console.WriteLine(a == b); // True — records have structural equality
Note: string is a reference type but overrides == for content equality, which is
why "hello" == "hello" is true even though they could theoretically be different
objects.
Recap
C# value types carry their data directly — assignment copies the data and each
variable is independent. Reference types store a pointer to heap data —
assignment copies the reference and mutations are shared. Value type local variables
live on the stack; value type fields of a class live on the heap inside the object.
Boxing converts a value type to a heap object — it costs a heap allocation and
GC pressure; avoid it in hot paths by using generics. Structs are appropriate for
small (≤16 B), immutable, value-semantics types; use classes for everything else.
The ref/out/in parameter modifiers control whether a variable is passed by value
or by reference and what mutation is allowed. Span<T> is the BCL's zero-allocation
memory-view primitive, implemented as a ref struct to guarantee stack-only lifetime.