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:

Example.cs
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:

Example.cs
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:

Example.cs
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:

Example.cs
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:

Example.cs
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:

Example.cs
// 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:

Example.cs
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:

Example.cs
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 Nullable Value Type

Value types cannot normally be null. The Nullable<T> wrapper (or T? syntax) adds null support:

Example.cs
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.