Value Types vs Reference Types in C#
Every type in C# falls into one of two categories: value types and reference types. This distinction determines how variables are stored, copied, and compared. Getting it wrong leads to subtle bugs; understanding it properly unlocks significant performance optimisations.
The Fundamental Difference
A value type variable holds the data directly. When you assign one value-type variable to another, you copy the data:
int a = 42;
int b = a; // b is an independent copy
b = 99;
Console.WriteLine(a); // 42 — unchanged
A reference type variable holds a reference (a pointer) to the data on the heap. Assignment copies the reference, not the data:
var listA = new List<int> { 1, 2, 3 };
var listB = listA; // Both point to the same list
listB.Add(4);
Console.WriteLine(listA.Count); // 4 — listA is affected
What Lives Where
Value types are typically stored on the stack when they are local variables, and inline within their containing object when they are fields. Reference types are always allocated on the managed heap, with a reference on the stack pointing to them.
Common value types: int, double, bool, char, decimal, DateTime, Guid, enum, and any custom struct.
Common reference types: string, object, arrays, class, interface, delegate, and record class.
Passing to Methods
By default, both value types and reference types are passed by value — but what is copied differs:
public void ModifyValue(int number)
{
number = 99; // Only modifies the local copy
}
public void ModifyList(List<int> items)
{
items.Add(99); // Modifies the original list via the shared reference
}
The ref and in keywords change this behaviour:
public void Increment(ref int number)
{
number++; // Modifies the caller's variable
}
public double CalculateDistance(in Point3D point)
{
// 'in' passes by reference but prevents modification
// Avoids copying a large struct
return Math.Sqrt(point.X * point.X + point.Y * point.Y + point.Z * point.Z);
}
Boxing and Unboxing
When a value type is assigned to a variable of type object or an interface, it is boxed — wrapped in a heap-allocated object:
int value = 42;
object boxed = value; // Boxing: allocates on the heap
int unboxed = (int)boxed; // Unboxing: copies back to the stack
Boxing is expensive relative to normal value-type operations. It allocates memory and creates garbage collection pressure. Common sources of accidental boxing:
// Boxing happens here — ArrayList stores objects, not ints
var list = new ArrayList();
list.Add(42); // Boxed
// No boxing — List<int> stores ints directly
var typedList = new List<int>();
typedList.Add(42); // No allocation
This is one reason generic collections replaced their non-generic predecessors.
Equality Semantics
Value types use value equality by default — two values are equal if all their fields are equal. Reference types use reference equality by default — two references are equal only if they point to the same object:
var point1 = new Point(3, 4);
var point2 = new Point(3, 4);
Console.WriteLine(point1.Equals(point2)); // True — same values
var obj1 = new object();
var obj2 = new object();
Console.WriteLine(obj1.Equals(obj2)); // False — different instances
This is why string overrides Equals — it is a reference type that behaves like a value type for equality purposes.
Designing Custom Structs
When creating a custom value type, follow these guidelines:
public readonly struct Colour
{
public byte R { get; }
public byte G { get; }
public byte B { get; }
public byte A { get; }
public Colour(byte r, byte g, byte b, byte a = 255)
{
R = r;
G = g;
B = b;
A = a;
}
}
Microsoft recommends using a struct when:
- The type logically represents a single value (like a coordinate or colour)
- The instance size is 16 bytes or smaller
- The type is immutable
- The type will not need to be boxed frequently
The Nullable Value Type
Value types cannot normally be null. The Nullable<T> wrapper (or T? syntax) adds null support:
int? maybeAge = null;
if (maybeAge.HasValue)
{
Console.WriteLine(maybeAge.Value);
}
// Null-coalescing provides a default
int age = maybeAge ?? 0;
Under the hood, Nullable<T> is itself a value type containing a bool flag and the value, so it avoids heap allocation.
Practical Impact
The value-type/reference-type distinction affects everything from parameter passing to collection performance to equality comparisons. Choosing the right category for your custom types — and understanding the behaviour of built-in types — prevents an entire class of bugs related to unintended sharing, unexpected equality results, and unnecessary allocations.