📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is the difference between value types and reference types in C#? Explain copy semantics, equality, where they are stored, and why "value types live on the stack" is only partly true.

Value types versus reference types in .NET 10: what each kind is, copy and assignment semantics, default equality, real storage locations, object overhead, and how to choose.

Junior Theory Intermediate csharp equality memory reference-types types value-types

🎯 Key points

  • Value types (struct, enum, primitives, record struct, ValueTuple) hold their data directly; reference types (class, record, interface, delegate, array, string) hold a reference to an object
  • Assignment and parameter passing copy the value for value types and copy the reference for reference types
  • Value types are stored inline wherever they are declared: in a stack frame or register for locals, inside the object for fields, inside the array for elements
  • Captured, async and iterator locals and boxed values move to the heap, so 'structs live on the stack' is a simplification
  • Every heap object has a header and a method table pointer (16 bytes of overhead on 64-bit) and is managed by the GC
  • Default equality: reference types compare identity; ValueType.Equals compares fields, using reflection when it cannot compare bits
  • Value types cannot be null (except Nullable<T>), are implicitly sealed and cannot have a user-defined parameterless finaliser
  • Choose a struct for small (about 16 bytes or less), immutable, value-like data; otherwise use a class

⏱ 30-second answer

A value type holds its data directly, so assigning or passing it copies the data; a reference type variable holds a reference to an object on the managed heap, so copying the variable makes two references to the same object. Value types are stored inline where they are declared: a local usually sits on the stack or in a register, but a struct field of a class lives inside that object on the heap, and captured or boxed values also end up on the heap. By default classes compare by identity and structs by their fields. I use structs for small, immutable values like Money or coordinates, and classes for everything with identity or behaviour.

🗣 Full answer (senior)

How I would answer this in an interview

The real difference is semantics, not location: a value-type variable contains the data, a reference-type variable contains a reference to an object on the managed heap. Everything else — copying, equality, nullability, where memory lives — follows from that.

1. What belongs to each

Value types Reference types
structs: int, double, decimal, bool, char, DateTime, Guid, TimeSpan, Span<T> classes: string, arrays, List<T>, your services and entities
enums interface types (the variable holds a reference)
record struct, tuples (ValueTuple) record (class), delegates, object, dynamic
Derive from System.ValueType Derive from System.Object directly or indirectly

2. Copy semantics

var p1 = new Point(1, 2);   // struct
var p2 = p1;                // copies the data
p2.X = 99;                  // p1.X is still 1

var c1 = new Customer { Name = "Ada" };  // class
var c2 = c1;                             // copies the reference
c2.Name = "Grace";                       // c1.Name is now "Grace" - same object
  • Assignment, passing arguments and returning values copy the value. For structs that means all the bytes; for classes it means just the reference (8 bytes on 64-bit).
  • This is why mutable structs are dangerous: you easily mutate a copy (list[0].X = 5 does not compile for that reason; foreach (var p in points) p.X = 5; mutates nothing useful).

3. Equality

Default Equals Default ==
Class Reference equality Reference equality (not defined unless overloaded)
Struct Field-by-field value equality via ValueType.Equals (reflection-based unless fields are "tightly packed" bit-comparable) Not defined for user structs unless you overload it
record / record struct Compiler-generated value equality Generated ==
string Value (ordinal) equality Overloaded to value equality

The default struct Equals can be very slow (reflection, boxing) — always implement IEquatable<T> on structs used as dictionary keys, or use a record struct.

4. Where they are stored — and why "value types live on the stack" is only partly true

The correct statement is: value types live wherever their containing variable lives.

  • A value-type local or parameter typically lives on the stack or in a CPU register.
  • A value-type field of a class lives inside that object on the heap.
  • Elements of an int[] live in the array on the heap.
  • A value-type local captured by a lambda, used across an await, or inside an iterator is hoisted into a heap-allocated closure or state machine.
  • A boxed value type is a heap object.
  • A static struct field lives in the type's static storage (on the heap in .NET).

Conversely, reference-type objects almost always live on the heap, while the reference variable itself may be on the stack. (.NET 9/10's JIT can even stack-allocate some small objects and arrays that provably do not escape, via escape analysis — so "objects are always on the heap" is also becoming only mostly true.)

The only value types guaranteed to stay on the stack are ref structs like Span<T> — the compiler forbids them from being boxed, captured or stored in heap fields.

5. Nullability and defaults

  • Value types cannot be null; their default is all-zero bits (0, false, DateTime.MinValue, Guid.Empty). Nullable<T> (int?) adds a HasValue flag.
  • Reference types default to null. Nullable reference types (string?) are a compile-time annotation only.

6. When I create a struct

Microsoft's guidance, which I follow: a struct should represent a single small value, be immutable (readonly struct), be small (≈16 bytes or less is ideal; beyond ~64 bytes copying costs add up), and not be boxed frequently. Good examples: Money, Coordinate, strongly-typed IDs (readonly record struct OrderId(Guid Value)). Everything with identity, a lifecycle or behaviour-heavy logic should be a class.

7. A production example

A telemetry pipeline stored millions of readings as a class Reading { DateTime At; double Value; }. Each object carried a 16-byte header plus padding, and the GC spent a lot of time tracing millions of small objects. Changing it to a readonly record struct Reading(DateTime At, double Value) stored in arrays packed the data contiguously (16 bytes per reading instead of ~40 including the reference), cut managed heap size substantially, and reduced Gen2 GC pause time. The opposite mistake also happens: a 200-byte mutable struct passed around by value caused both slow copies and "lost update" bugs, and was correctly turned back into a class.

🔍 What the interviewer is checking

A foundational question that separates memorised slogans from real understanding.

Green flags

  • Defines the difference by copy semantics.
  • Gives correct exceptions to the stack/heap rule (fields, closures, async, boxing).
  • Knows default equality differences and the reflection cost of ValueType.Equals.
  • States when to choose a struct and why.

Red flags

  • Only says "stack versus heap".
  • Thinks passing an object to a method copies the object.
  • Recommends structs for performance without mentioning size or copying.

📚 Learn in depth

1. Object layout on the managed heap

Every reference-type object on 64-bit .NET has:

[ object header (8 bytes) ][ MethodTable pointer (8 bytes) ][ fields ... ]
             ^ sync block index / hash code / lock info
  • The MethodTable pointer identifies the type (used for virtual dispatch, casting, GC).
  • The minimum object size is 24 bytes even with no fields.
  • A reference to the object points at the MethodTable pointer, not the header.

A struct has no header and no MethodTable pointer when stored inline — just its fields (plus padding for alignment). That is why an array of 1,000,000 ints is ~4 MB, while 1,000,000 boxed ints are ~24 MB plus 8 MB of references.

2. How the stack and registers hold values

When the JIT compiles a method, value-type locals are assigned to registers where possible, otherwise to slots in the method's stack frame. Struct locals may be promoted (each field gets its own register — "struct promotion") if the struct is small and not address-exposed. Taking a ref to a struct, passing it by ref, or calling non-inlined methods on it can prevent promotion, so it lands in memory.

Reference-type locals are just pointer-sized values in registers or stack slots; the GC scans them as roots at safe points to find live objects.

3. Why "stack vs heap" is an implementation detail

The C# specification only defines semantics: value types have copy semantics, reference types have reference semantics. Where memory lives is a runtime choice:

  • The compiler moves captured locals into closure classes (heap).
  • async methods move locals into state machine structs that are boxed to the heap on the first real suspension.
  • yield iterators move locals into iterator classes.
  • Escape analysis (.NET 9 started stack-allocating boxes and small non-escaping objects; .NET 10 extended it to small arrays and delegates) can put reference-type objects on the stack when the JIT proves they never escape the method.

Eric Lippert's well-known point: describing value types as "stack types" teaches the wrong mental model. Teach semantics first.

4. ref struct — the true stack-only types

Span<T>, ReadOnlySpan<T>, Utf8JsonReader and your own ref structs must live on the stack because they may contain managed pointers (ref fields) into stack memory or the middle of objects. The compiler enforces:

  • No boxing, no conversion to object or interfaces (C# 13 allows implementing interfaces and generic allows ref struct constraints, but still no boxing).
  • No fields of ref struct type inside classes or normal structs.
  • No capture in lambdas, and no use across await or yield (relaxed in C# 13 when not crossing the suspension point).

5. Struct equality internals

ValueType.Equals(object):

  1. Checks the other object is the same type.
  2. If the struct has no reference fields and no padding/float quirks ("can compare bits"), it does a fast memcmp of the bytes.
  3. Otherwise it uses reflection to compare each field — orders of magnitude slower, and it boxes.

ValueType.GetHashCode historically hashed only the first non-null field, which can produce terrible hash distribution in dictionaries (e.g. a struct whose first field is a constant). That is why structs used as keys should implement IEquatable<T> and GetHashCode with HashCode.Combine, or be record structs, which generate both.

6. Copy costs and defensive copies

Copying a struct is a memcpy of its size. For small structs this is cheaper than a heap allocation plus GC. For large structs, copies on every assignment, argument and return add up, and the compiler creates defensive copies when calling non-readonly members on readonly locations (readonly fields, in parameters). Marking the struct readonly removes them.

7. Arrays of structs vs arrays of classes

Point[] (struct):   [X][Y][X][Y][X][Y]...         contiguous, cache-friendly
Point[] (class):    [ref][ref][ref]... -> objects scattered across the heap

Iterating an array of structs reads sequential memory, which CPU prefetchers love; iterating references causes a potential cache miss per element. This is the core of data-oriented design and why high-performance .NET code (game engines, parsers, ML.NET) uses structs in arrays and spans.

Try it yourself

  1. Use ObjectLayoutInspector (NuGet) to print the layout of a small class and the equivalent struct.
  2. Measure GC.GetAllocatedBytesForCurrentThread() before and after creating 1,000,000 class instances vs filling a struct array.
  3. Benchmark a struct dictionary key with and without IEquatable<T>.
  4. Mutate a struct inside foreach and inside a List<T> via the indexer to see copy semantics bite.

💻 Code

namespace Types;

public readonly record struct GeoPoint(double Lat, double Lon);   // value type, value equality
public sealed class Order { public decimal Total { get; set; } }  // reference type

public struct MutablePoint { public int X; public int Y; }

public static class ValueVsReference
{
    public static void Main()
    {
        // Copy semantics
        var a = new MutablePoint { X = 1, Y = 2 };
        var b = a;          // copy
        b.X = 99;
        Console.WriteLine(a.X);                 // 1

        var o1 = new Order { Total = 10m };
        var o2 = o1;        // same object
        o2.Total = 99m;
        Console.WriteLine(o1.Total);            // 99

        // Equality
        Console.WriteLine(new GeoPoint(1, 2) == new GeoPoint(1, 2));            // True (record struct)
        Console.WriteLine(new Order() == new Order());                          // False (identity)

        // Struct inside an array is stored inline: modify through the array element
        var points = new MutablePoint[3];
        points[0].X = 5;                        // modifies the element in place
        var list = new List<MutablePoint> { new() };
        // list[0].X = 5;                       // CS1612: list[0] returns a copy
        var copy = list[0]; copy.X = 5; list[0] = copy;

        // Passing to a method copies the struct, not the class
        Bump(a); BumpOrder(o1);
        Console.WriteLine($"{a.X} {o1.Total}"); // 1 100
    }

    private static void Bump(MutablePoint p) => p.X++;
    private static void BumpOrder(Order o) => o.Total++;
}

⚠ Common mistakes

1. Saying "value types always live on the stack"

What happens: candidates claim int fields of a class are on the stack, or that structs never cause GC work. Why it is wrong: value types live where their container lives — inside heap objects, arrays, closures and async state machines. Do instead: explain copy semantics first, then location as an implementation detail; mention ref struct as the only stack-only guarantee.

2. Mutable structs

What happens: struct Counter { public int Value; } modified through collections, properties or foreach. Why it is wrong: you mutate copies, so updates are silently lost (list[0].Value++ does not compile; GetCounter().Value++ changes a temporary). Do instead: make structs readonly and return new values (with expressions on record structs).

3. Large structs passed around by value

What happens: a 100+ byte struct is used for DTOs or domain objects "to avoid GC". Why it is wrong: every assignment, argument and return copies all the bytes, often costing more than the allocation it saved. Do instead: keep structs small (≈16 bytes ideal); use classes for larger data, or pass large readonly structs by in after benchmarking.

4. Using structs as dictionary keys without equality members

What happens: a custom struct key relies on ValueType.Equals and GetHashCode. Why it is wrong: reflection-based equality is slow and boxes, and the default hash can collide badly. Do instead: implement IEquatable<T> and GetHashCode with HashCode.Combine, or use a readonly record struct.

5. Assuming assigning a class instance copies it

What happens: var copy = original; copy.Status = Cancelled; expecting original to be untouched. Why it is wrong: both variables reference the same object. Do instead: clone explicitly or use records with with expressions for non-destructive mutation.

6. Comparing class instances with == expecting value equality

What happens: two Customer objects with the same data compare unequal. Why it is wrong: classes use reference equality unless you override Equals/==. Do instead: use records for data-centric types, or implement equality deliberately (and consistently with GetHashCode).

7. Choosing struct vs class for "performance" without measuring

What happens: entities or services are converted to structs to reduce allocations. Why it is wrong: structs boxed into interfaces, object or non-generic collections allocate anyway, and copying can dominate. Do instead: measure with BenchmarkDotNet and a memory profiler; use structs for small, immutable values with clear value semantics.

8. Forgetting default values of structs

What happens: default(OrderId) or an uninitialised struct field is treated as valid. Why it is wrong: structs can always be zero-initialised, bypassing constructors — e.g. an OrderId with Guid.Empty. Do instead: validate at boundaries, and consider whether default is a meaningful value for the type.

9. Boxing value types accidentally

What happens: structs stored in object, ArrayList, non-generic interfaces or passed to string.Format with object parameters. Why it is wrong: each box is a heap allocation, defeating the purpose of a struct. Do instead: use generics, IEquatable<T>, and interpolated string handlers that avoid boxing.

10. Equating nullability with reference types

What happens: assuming DateTime can be null in a DTO, or that a missing JSON value will become null. Why it is wrong: value types are never null; missing values deserialise to default (e.g. 0001-01-01), which can be written to the database as a real date. Do instead: use DateTime?/DateOnly? when absence is meaningful, and mark required properties with required.

🏛 Senior insight: trade-offs

Seniors frame this as semantics first, performance second, and know when the trade-off flips.

Concern Struct wins Class wins
Millions of small items in arrays inline storage, no per-object overhead, cache locality
Data passed around a lot and large one reference copied, not all bytes
Identity, lifecycle, polymorphism yes
Used through interfaces or object structs box
Thread safety immutable structs are trivially safe to copy shared mutable objects need locks
  • Measure with BenchmarkDotNet and [MemoryDiagnoser], and check GC behaviour with dotnet-counters, rather than guessing.
  • In domain models, small value objects (Money, Percentage, PostCode) as readonly record struct remove primitive obsession at almost no cost.
  • Watch EF Core and serialisers: value objects map best as owned types or complex properties (EF Core 8+), not as entities.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate mentions stack and heap storage. Why: Probes the memory model behind the stack/heap simplification.
    How to answer

    The stack is a per-thread region that holds method frames: locals, arguments, return addresses and saved registers. Allocating is just moving the stack pointer, and everything is freed when the method returns. The heap is shared memory for objects whose lifetime is not tied to one method; the generational garbage collector frees them once they are unreachable.

    The common line "value types live on the stack, reference types on the heap" is only half true. Storage follows the declaration: a value-type local usually lives on the stack (or in a register), but a value-type field lives inside its object on the heap, and boxing, lambdas that capture locals, iterators and async methods move data to the heap. Reference-type objects are conceptually heap objects (only the reference sits on the stack), although the current JIT's escape analysis can place a small object that provably never leaves the method on the stack as an optimisation. The language guarantees lifetime and copy semantics, not a location.

  2. When: The candidate says value types derive from System.ValueType and object. Why: Treating a value type as object or an interface leads straight to boxing.
    How to answer

    Boxing wraps a value type in a new heap object so it can be used as object or an interface; unboxing checks the object's type and copies the value back, and it must be the exact type or you get InvalidCastException. Every box is a heap allocation, so in hot paths it creates GC pressure. Hidden boxing comes from non-generic APIs like ArrayList and params object[], passing structs as interfaces, calling GetType() or non-overridden Equals/GetHashCode on structs, and enumerating a List<T> through IEnumerable<T>. I find it with BenchmarkDotNet's MemoryDiagnoser or an allocation profiler and fix it with generics, constraints and IEquatable<T>.

  3. When: The candidate contrasts identity equality with field equality. Why: Tests whether the candidate can implement equality correctly for both kinds of type.
    How to answer

    System.Object gives every type Equals, GetHashCode, ToString, GetType, the protected MemberwiseClone and Finalize, plus static ReferenceEquals. The review problem is the Equals/GetHashCode contract: equal objects must return equal hash codes. If Equals compares Email but GetHashCode is still identity-based, two "equal" customers land in different hash buckets, so Dictionary, HashSet, Distinct and GroupBy treat them as different. I'd implement IEquatable<Customer>, override both methods using the same immutable fields, compare emails case-insensitively in both, overload ==/!=, and seal the class. For value-like data a record generates all of this.

↩ Usually asked after

More in Types

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me