Question
What’s the difference between value types and reference types in C#, and why does it matter for correctness and performance?
Answer
Every variable in C# holds either a value type or a reference type. The distinction governs how data is copied, where it lives in memory, how equality works, and what the performance characteristics of your code are. Getting this wrong is one of the most common sources of subtle bugs in C# — especially when working with structs in collections.
The core difference
Value types (int, bool, double, struct, enum, record struct) store their data directly. Assigning one variable to another copies the entire value. The two variables are independent — changing one has no effect on the other.
Reference types (class, string, arrays, delegates, record) store a reference — essentially a pointer to an object on the managed heap. Assigning one variable to another copies the reference, not the object. Both variables now point to the same object, so a change through one is visible through the other.
Where do they live in memory?
This is one of the most frequently misunderstood points, and one you will encounter in technical interviews, so it’s worth being precise:
- Reference types always allocate their object on the managed heap, regardless of where the variable is declared. The variable itself holds a reference (a pointer-sized value) to that heap object.
- Value types are stored inline, wherever they happen to live:
- A local variable declared inside a method → typically on the stack (or in a CPU register if the JIT optimises it away entirely).
- A field of a class → stored inside the heap object, not as a separate allocation.
- An element of a value-type array → stored contiguously inside the array object on the heap.
The key insight: “value types live on the stack” is an oversimplification. The correct rule is that value types live inline where they are declared. A struct field inside a class lives on the heap — but still inline, without a separate allocation. This inline storage is what gives value-type arrays their cache-friendly contiguous layout.
Let’s work through examples to make every part of this concrete.
Example A — Copy vs. reference semantics
struct Point
{
public int X, Y;
}
class Person
{
public string Name;
}
var p1 = new Point { X = 1, Y = 2 };
var p2 = p1; // full copy of the value
p2.X = 99;
Console.WriteLine(p1.X); // 1 — p1 is unaffected; p2 is an independent copy
Console.WriteLine(p2.X); // 99
var a = new Person { Name = "Alice" };
var b = a; // copies the reference, not the object
b.Name = "Bob";
Console.WriteLine(a.Name); // "Bob" — a and b point to the same object
Console.WriteLine(b.Name); // "Bob"
Struct assignment gives you two independent values. Class assignment gives you two variables sharing one object. This is the foundation everything else builds on.
Example B — Boxing and its costs
Boxing occurs when a value type is treated as object or as an interface. The runtime allocates a new heap object, copies the value into it, and returns a reference. This is invisible in source code, which makes it easy to introduce inadvertently.
int i = 42; object boxed = i; // boxing: a new heap allocation is made, 42 is copied into it Console.WriteLine(boxed.GetType().Name); // "Int32" Console.WriteLine((int)boxed); // 42 — unboxing: copies the value back out // Boxing creates a new, distinct object each time int x = 10; object ox = x; // box 1 object oy = x; // box 2 — a separate allocation Console.WriteLine(ox == oy); // False — reference equality; two different heap objects Console.WriteLine(ox.Equals(oy)); // True — value equality; both contain 10 // Avoid boxing in hot paths by using generic collections var generic = new List<int>(); // no boxing — int stored directly // var legacy = new ArrayList(); // would box every int added
The performance concern is not the cost of a single box — it’s the cost of boxing in a loop running millions of times, because each box is a heap allocation that increases GC pressure. The fix is almost always to use generic APIs (List<int> instead of ArrayList, generic interfaces instead of object parameters).
Example C — The mutable struct pitfall
Because structs are copied on assignment, mutation behaves differently from what class developers expect. The most common trap is modifying a struct retrieved from a collection.
public struct Counter
{
public int Value;
}
var list = new List<Counter> { new Counter { Value = 0 } };
// This looks like it modifies the element — it doesn't
foreach (var c in list)
{
var copy = c;
copy.Value++; // modifies the local copy, not the element in the list
}
Console.WriteLine(list[0].Value); // 0 — the list element is unchanged
// Correct approach: retrieve, modify, write back
var tmp = list[0];
tmp.Value++;
list[0] = tmp;
Console.WriteLine(list[0].Value); // 1
// Or, for arrays, use a ref local to avoid the copy entirely
var arr = new Counter[] { new Counter { Value = 0 } };
ref Counter refElement = ref arr[0];
refElement.Value++;
Console.WriteLine(arr[0].Value); // 1 — modified in place via ref local
The general guideline: make structs immutable (readonly struct). If a concept genuinely needs mutation, ask whether it should be a class instead. Mutable structs are a source of confusion because their copy semantics conflict with the intuition that “modifying an object modifies the original.”
Example D — Large structs, readonly struct, and the in parameter
Structs are copied every time they are passed to a method by value. For small structs (two or three fields), the copy is cheaper than a heap allocation. For larger structs, the copy cost becomes significant. The in modifier passes a struct by readonly reference — the callee receives a reference to the original value and cannot modify it, so you get the safety of value semantics without the copy cost.
Declaring a struct as readonly removes another subtle cost: the defensive copy. Without readonly, the compiler sometimes copies a struct before calling a member on it (to prevent the member from accidentally mutating a value it shouldn’t). With readonly struct, the compiler knows no mutation is possible and skips the copy.
public readonly struct Matrix3x3
{
public readonly double M11, M12, M13;
public readonly double M21, M22, M23;
public readonly double M31, M32, M33;
public Matrix3x3(double m11, double m12, double m13,
double m21, double m22, double m23,
double m31, double m32, double m33)
=> (M11, M12, M13, M21, M22, M23, M31, M32, M33)
= (m11, m12, m13, m21, m22, m23, m31, m32, m33);
public double Determinant() =>
M11 * (M22 * M33 - M23 * M32) -
M12 * (M21 * M33 - M23 * M31) +
M13 * (M21 * M32 - M22 * M31);
}
// 'in' passes by readonly reference — no 72-byte copy on each call
static double Det(in Matrix3x3 m) => m.Determinant();
var m = new Matrix3x3(1, 2, 3, 4, 5, 6, 7, 8, 9);
double result = Det(in m);
Console.WriteLine(result); // 0 (singular matrix — determinant is zero)
// Cache-locality benefit: struct arrays store data contiguously
var points = new Matrix3x3[1000]; // 1000 × 72 bytes, one contiguous block on the heap
// An array of class instances would be 1000 separate heap objects + 1000 pointers to them
Console.WriteLine($"Array element size: {System.Runtime.InteropServices.Marshal.SizeOf<Matrix3x3>()} bytes");
// 72 bytes — all in one block, no pointer chasing
Example E — ref, out, and in parameters
These three modifiers all pass by reference but with different contracts:
ref— the caller must initialise the variable; the callee can read and write it.out— the callee must write before it returns; the caller does not need to initialise.in— the caller initialises; the callee can only read (readonly reference).
static void Increment(ref int x) => x++;
static bool TryParsePositive(string s, out int value)
{
if (int.TryParse(s, out value) && value > 0) return true;
value = default;
return false;
}
static int Dot(in (int X, int Y) a, in (int X, int Y) b) => a.X * b.X + a.Y * b.Y;
int n = 5;
Increment(ref n);
Console.WriteLine(n); // 6 — the original variable was modified
bool parsed = TryParsePositive("10", out int v);
Console.WriteLine(parsed); // True
Console.WriteLine(v); // 10
int dotProduct = Dot((1, 2), (3, 4));
Console.WriteLine(dotProduct); // 11 — (1×3) + (2×4)
Use these modifiers sparingly in application code — they reduce readability. Reserve them for performance-critical paths (avoiding large struct copies) or interop scenarios. The related advanced concept is ref struct (used by Span<T>), which is stack-only and cannot be boxed, stored in fields, or captured by lambdas — covered in the Span lesson.
Example F — Nullable value types, defaults, and string interning
Value types cannot normally be null — their default is a zeroed representation (0, false, DateTime.MinValue, etc.). Nullable<T> (written as T?) wraps a value type with a boolean HasValue flag to allow a null state, which compiles to a two-field struct — no heap allocation.
int? maybe = null;
Console.WriteLine(maybe.HasValue); // False
Console.WriteLine(maybe.GetValueOrDefault()); // 0 — safe default when null
maybe = 7;
Console.WriteLine(maybe.HasValue); // True
Console.WriteLine(maybe.Value); // 7
Console.WriteLine(default(int)); // 0
Console.WriteLine(default(bool)); // False
Console.WriteLine(default(DateTime)); // 01/01/0001 00:00:00
// Strings: reference type, but immutable and subject to interning
// Identical string literals are typically interned to the same instance
string a = "hello";
string b = "hello";
Console.WriteLine(object.ReferenceEquals(a, b)); // True — same interned instance
// A dynamically constructed string is a distinct object
string c = new string(new[] { 'h', 'e', 'l', 'l', 'o' });
Console.WriteLine(object.ReferenceEquals(a, c)); // False — separate heap object
Console.WriteLine(a == c); // True — string overloads == for value equality
string d = string.Intern(c);
Console.WriteLine(object.ReferenceEquals(a, d)); // True — intern pool returns the cached instance
String deserves special mention: it is a reference type, but it behaves like a value type in most code because it is immutable and overloads == for value equality. Never rely on reference equality for strings — always use == or string.Equals.
Example G — Where do they live? (memory address proof)
This example requires AllowUnsafeBlocks=true in your project file and should be run in Release configuration. It uses unsafe pointers purely to make the memory layout observable — this is a diagnostic technique, not something you would write in application code.
// Requires: <AllowUnsafeBlocks>true</AllowUnsafeBlocks> in .csproj
// Run: dotnet run -c Release
using System.Runtime.InteropServices;
unsafe
{
// 1. Local value type — lives on the stack
int local = 123;
int* pLocal = &local;
Console.WriteLine($"Local int (stack) : 0x{((nint)pLocal):X}");
// 2. Value-type field inside a reference type — lives inside the heap object
var box = new Box { P = new PointV2(1, 2) };
var handle = GCHandle.Alloc(box, GCHandleType.Pinned); // prevent GC from moving it
try
{
byte* objPtr = (byte*)handle.AddrOfPinnedObject();
Console.WriteLine($"Box object (heap) : 0x{((nint)objPtr):X}");
fixed (PointV2* pField = &box.P)
{
Console.WriteLine($"Box.P field(heap) : 0x{((nint)pField):X} (inside the heap object, no separate allocation)");
}
}
finally { handle.Free(); }
// 3. Value-type array elements — contiguous on the heap
var arr = new PointV2[] { new(10, 20), new(30, 40), new(50, 60) };
fixed (PointV2* p0 = &arr[0])
{
Console.WriteLine($"arr[0] (heap) : 0x{((nint)(p0 + 0)):X}");
Console.WriteLine($"arr[1] (heap) : 0x{((nint)(p0 + 1)):X} (+{sizeof(PointV2)} bytes — contiguous)");
Console.WriteLine($"arr[2] (heap) : 0x{((nint)(p0 + 2)):X} (+{sizeof(PointV2)} bytes — contiguous)");
}
}
struct PointV2 { public int X, Y; public PointV2(int x, int y) { X = x; Y = y; } }
sealed class Box { public PointV2 P; }
// Example output (addresses vary per run):
// Local int (stack) : 0x57C9B3F9AC
// Box object (heap) : 0x1F8A240020
// Box.P field(heap) : 0x1F8A240028 (inside the heap object, no separate allocation)
// arr[0] (heap) : 0x1F8A240060
// arr[1] (heap) : 0x1F8A240068 (+8 bytes — contiguous)
// arr[2] (heap) : 0x1F8A240070 (+8 bytes — contiguous)
Three things to observe in the output. First, the local variable’s address is in a completely different (much higher, on most platforms) address range than the heap objects — that’s the stack. Second, the Box.P field address is a few bytes after the Box object address, not a separate allocation — the struct lives inside the class object. Third, the array elements are exactly sizeof(PointV2) bytes apart — no pointers between them, pure contiguous data. That last property is why struct arrays have better cache performance than arrays of class references.
Summary and Common Pitfalls
- Default to classes for objects with identity, complex state, or inheritance. Use structs for small, logically immutable values (
Point,Color,Range). - Avoid mutable structs. If you need to update a struct in a collection, retrieve it, modify it, and write it back explicitly — or use a
reflocal. Mutating a copy silently does nothing. - Watch for boxing in hot paths. Generic collections, generic interfaces, and
IEquatable<T>implementations avoid it. - Large structs cost on every copy. Mark them
readonly structand pass them withinto eliminate both the copy and defensive copies. - Never compare strings with
ReferenceEquals. Use==orstring.Equals— interning behaviour is an implementation detail, not a contract.