Skip to content

.NET Core · Fundamentals

Value Types vs Reference Types in C#

9 min read Updated 2026-06-22 Share:

Practice Value vs Reference Types interview questions

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 asWhere the data lives
Local variable of a value typeStack (method frame)
Local variable of a reference typeStack 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 elementHeap — 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:

  1. A heap allocation — the GC must find space on the managed heap.
  2. A memory copy — the value's bytes are copied into the new heap object.
  3. 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:

Concernstructclass
Type categoryValue typeReference type
Default valueZero-initialised (no null)null
InheritanceCannot inherit from another struct/class; can implement interfacesFull inheritance hierarchy
new in local scopeStack-allocated (no GC pressure)Heap-allocated
Equality defaultStructural (member-wise via ValueType.Equals)Identity (same object)
NullablePoint? 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.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel