Skip to content

Value vs Reference Types Interview Questions & Answers

15 questions Updated 2026-06-22 Share:

C# value types vs reference types — stack and heap allocation, boxing costs, struct vs class trade-offs, ref parameters, and Span<T>.

Read the in-depth guideValue Types vs Reference Types in C#(opens in new tab)
15 of 15

Value types hold their data directly. Reference types hold a pointer (reference) to data stored on the managed heap. Assigning a value type copies the data; assigning a reference type copies only the reference — both variables then point to the same object.

// Value type — copy semantics
int a = 5;
int b = a;   // b gets a copy of 5
b = 99;
Console.WriteLine(a); // 5 — a is unaffected

// Reference type — shared reference
var list1 = new List<int> { 1, 2, 3 };
var list2 = list1;   // list2 points to the SAME list
list2.Add(4);
Console.WriteLine(list1.Count); // 4 — list1 sees the change

Value types: int, double, bool, char, decimal, DateTime, struct, enum. Reference types: class, string, object, arrays, delegates, interfaces. string behaves like a value type in practice because it is immutable — any "change" creates a new string object.

Rule of thumb: If you need copy-on-assign semantics and the data is small and short-lived, use a value type (struct). For anything polymorphic, large, or long-lived, use a class.

The common interview answer — "value types on the stack, reference types on the heap" — is an oversimplification. What matters is how the variable is declared.

void Method()
{
    int x = 10;             // local value type → stack frame
    object obj = new();     // 'new object' → heap; reference 'obj' → stack
}

class Wrapper
{
    int field = 42;         // value type field inside a class → HEAP (embedded in the object)
    string name = "hello";  // reference type field → heap (reference stored in object, string on heap)
}

// Struct stored as a field of a class lives on the heap inside the class:
class Circle { public System.Drawing.Point Center; } // Point (struct) is on heap

Local variables of value types live on the stack of the current method frame. Value type fields inside a class live on the heap as part of the object. Reference types always allocate their data on the heap; the variable (reference) itself can be on either stack or heap depending on context.

Rule of thumb: Think "where is the variable declared?" not "what type is it?" — the location follows the container, not the type.

Boxing wraps a value type in a heap-allocated System.Object wrapper. Unboxing extracts the value type back out. Both operations are implicit but incur hidden costs.

int x = 42;
object boxed = x;         // BOXING: heap allocation + copy
int unboxed = (int)boxed; // UNBOXING: type check + copy

// Hidden boxing in older APIs:
var al = new ArrayList();
al.Add(42);               // boxes 42 to object — heap allocation!
int v = (int)al[0];       // unboxes — type check + copy

// Generic collections avoid boxing entirely:
var list = new List<int>();
list.Add(42);             // no boxing — int stored directly
int v2 = list[0];         // no unboxing

Boxing costs: a heap allocation, a memory copy, and eventual GC pressure. In a tight loop adding thousands of ints to an ArrayList, this adds up significantly. String interpolation pre-.NET 6 also caused boxing for value types passed as object. The fix is always genericsList<int> instead of ArrayList, IComparable<T> instead of IComparable.

Rule of thumb: Avoid boxing in hot paths. If you see object parameters or non-generic collections in performance-sensitive code, that's a boxing smell.

A struct is a value type; a class is a reference type. The difference determines copy semantics, memory layout, inheritance rules, and GC behavior.

struct Point { public int X, Y; }   // value type
class Circle { public int Radius; } // reference type

var p1 = new Point { X = 1, Y = 2 };
var p2 = p1;   // copy — p2 is independent
p2.X = 99;
Console.WriteLine(p1.X); // 1 — unaffected

var c1 = new Circle { Radius = 5 };
var c2 = c1;   // shared reference
c2.Radius = 99;
Console.WriteLine(c1.Radius); // 99 — both see the change

Key differences:

struct class
Type Value type Reference type
Default Zero-initialised, no null null
Inheritance Cannot inherit (can implement interfaces) Full inheritance
new Stack-allocated (if local) Always heap-allocated
IDisposable Valid but uncommon Common

Rule of thumb: Use struct for small (≤16 bytes), immutable, logically value-like data (coordinates, colours, Guid). Use class for everything else.

Prefer a struct when the type is small, immutable, frequently copied in bulk (e.g., array of coordinates), and semantically represents a single value rather than an entity with identity.

// Good struct candidate: 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);
}

// Bad struct candidate: large, mutable, reference identity matters
// struct HttpClient { ... } // wrong — should be class

The CLR can lay out arrays of structs as contiguous memory blocks, which is CPU-cache-friendly — a big win for game engines and numerical code. The .NET framework uses structs for: int, DateTime, Guid, KeyValuePair<K,V>, System.Numerics.Vector3, and Span<T>.

Avoid structs when: the type is larger than ~16 bytes (copying costs outweigh allocation savings), it is mutable (mutation bugs are hard to spot with copy semantics), or it needs to be null (use T? or a class).

Rule of thumb: Default to class. Switch to struct only when profiling shows GC pressure from many small, short-lived objects of that type.

In C#, all arguments are passed by value by default — a copy of the argument is made. For value types, the copy is the data itself. For reference types, the copy is the reference (pointer), not the object it points to.

void MutateList(List<int> list)
{
    list.Add(99);     // mutates the shared object — caller sees this
    list = new List<int>(); // rebinds local copy of reference — caller doesn't see this
}

var nums = new List<int> { 1, 2 };
MutateList(nums);
Console.WriteLine(nums.Count); // 3 — Add(99) was visible; reassignment was not

"Pass by value" for reference types means the reference is copied. Both the caller and callee hold separate copies of the reference that point to the same object. Mutating the object through the callee's reference is visible to the caller. Reassigning the callee's reference variable does not affect the caller.

Rule of thumb: Pass by value = copy of the handle, not the object. If you need to replace the caller's reference itself, use ref or return the new value.

The ref keyword passes a variable by reference — the method receives an alias to the caller's variable, not a copy. Changes to the parameter directly modify the caller's variable, including reassignment.

void Double(ref int value) => value *= 2;

int x = 5;
Double(ref x);           // must use 'ref' at call site too
Console.WriteLine(x);   // 10 — x was modified in-place

// ref with reference types — can replace the caller's reference:
void ReplaceList(ref List<int> list)
{
    list = new List<int> { 99 }; // caller's variable now points to new list
}

var nums = new List<int> { 1, 2 };
ReplaceList(ref nums);
Console.WriteLine(nums[0]); // 99

ref is commonly used in high-performance code to avoid copying large structs, in TryGetValue-style patterns, and in ref return / ref local to expose references into arrays or data structures without copying.

Rule of thumb: Use ref when the method needs to reassign the caller's variable, or to avoid copying large structs in performance-critical code.

out is like ref — it passes by reference — but with two differences: the variable does not need to be initialised before the call, and the method must assign it before returning.

// Classic Try-Parse pattern:
bool success = int.TryParse("42", out int result);
// 'result' was not initialised before the call — that's fine with 'out'
Console.WriteLine(success ? result : -1); // 42

// Inline declaration (C# 7+):
if (double.TryParse("3.14", out double pi))
    Console.WriteLine(pi); // 3.14

// Discard when you don't need the out value:
bool isValid = int.TryParse(input, out _);
ref out
Must be initialised before call Yes No
Must be assigned in method No Yes
Use case Two-way data exchange Return multiple values

Rule of thumb: Use out for the Try-Parse / Try-Get pattern where the method signals success/failure via return value and returns data via out. Use ref when the method needs to read the initial value too.

The in modifier passes a variable by reference but guarantees the method will not modify it — it is a read-only reference. This avoids copying large structs while providing compile-time safety that the callee cannot mutate.

readonly struct Matrix4x4 { /* 64 bytes */ }

// Without 'in': 64-byte copy on every call
float Determinant(Matrix4x4 m) => /* ... */ 0f;

// With 'in': passed by reference, no copy, callee cannot mutate
float Determinant(in Matrix4x4 m) => /* ... */ 0f;

var mat = new Matrix4x4();
float det = Determinant(in mat); // 'in' optional at call site for value types

in is most valuable for large structs in hot paths (rendering, physics, linear algebra). For small structs (≤pointer size), it may actually be slower because the CPU must dereference the pointer. Benchmark before adding in to small structs.

Rule of thumb: Add in to struct parameters larger than a pointer (8 bytes on 64-bit) in performance-sensitive code. Leave it off for small structs and all reference types (reference types already pass the pointer, not the object).

A ref struct is a struct that is guaranteed to live only on the stack. The CLR enforces this at compile time — you cannot box it, store it as a class field, use it as a generic type argument, or capture it in a lambda or async method.

ref struct StackOnly
{
    public int Value;
}

// Compile-time error — cannot box a ref struct:
// object obj = new StackOnly();
// Cannot be a field of a class:
// class Wrapper { StackOnly s; }
// Cannot use in async context:
// async Task Foo() { var s = new StackOnly(); await Task.Delay(1); }
// Correct use — short-lived, stack-bound:
void Process(Span<byte> data)
{
    // Span<T> is itself a ref struct — stack-only
    var slice = data[..10]; // fine: slice is also Span<T> (ref struct)
}

The primary use case is Span<T> and ReadOnlySpan<T> — high-performance, safe wrappers over contiguous memory (arrays, stack memory, unmanaged memory) that must not outlive their source. ref struct is the mechanism that enforces this lifetime.

Rule of thumb: ref struct is an advanced, performance-focused feature. You'll mostly encounter it as Span<T>. Only define your own when you need a stack-bound type with lifetime guarantees.

Span<T> is a stack-only, type-safe view over a contiguous block of memory. It can point to a managed array, a stack-allocated buffer, or unmanaged memory — without copying any data.

// Over a managed array — no copy:
int[] arr = { 1, 2, 3, 4, 5 };
Span<int> span = arr.AsSpan(1, 3); // points to arr[1..3] = {2, 3, 4}
span[0] = 99;
Console.WriteLine(arr[1]); // 99 — same memory

// Stack-allocated buffer — zero heap allocation:
Span<byte> buffer = stackalloc byte[256];
buffer.Fill(0);

// String parsing without allocations (ReadOnlySpan<char>):
ReadOnlySpan<char> line = "2026-06-22".AsSpan();
int year = int.Parse(line[..4]); // parses "2026" without allocating a substring

Span<T> is a ref struct because it stores an interior pointer into managed memory. If it were allowed on the heap (as a class field or boxed value), the GC could move the pointed-to array while the span's pointer still held the old address. Being stack-only ensures the span is always used within the lifetime of its source.

Rule of thumb: Use Span<T> / ReadOnlySpan<T> to eliminate string and array slice allocations in hot paths. It is the .NET replacement for char* / byte* unsafe pointers.

By default, value types compare by content (structural equality); reference types compare by identity (same object in memory). This comes from how Equals() and == are implemented on System.ValueType vs System.Object.

// Value type — structural equality by default:
var d1 = new DateTime(2026, 1, 1);
var d2 = new DateTime(2026, 1, 1);
Console.WriteLine(d1 == d2);      // True — same data
Console.WriteLine(d1.Equals(d2)); // True

// Reference type — 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 is overridden)

// string is a reference type but overrides == for value equality:
string s1 = "hello", s2 = "hello";
Console.WriteLine(s1 == s2); // True — string overrides ==

To get value equality on a class, override Equals() and GetHashCode(), and optionally overload ==. C# 9 records do this automatically.

Rule of thumb: Value types are equal when their data is equal. Reference types are equal when they are the same object — override Equals/GetHashCode to change that, or use a record which auto-generates value equality.

A readonly struct is a struct where all fields and auto-properties are implicitly read-only. The compiler enforces immutability: you cannot assign to any field after construction, and calling instance methods on a readonly struct variable does not require a defensive copy.

readonly struct Temperature
{
    public double Celsius { get; }
    public double Fahrenheit => Celsius * 9 / 5 + 32;

    public Temperature(double celsius) => Celsius = celsius;

    // Compiler error if you try:
    // public void SetCelsius(double c) { Celsius = c; } // CS1604
}

// The compiler no longer emits a defensive copy when calling methods:
readonly Temperature t = new Temperature(100);
Console.WriteLine(t.Fahrenheit); // 212 — no defensive copy needed

Without readonly, calling any instance method on a readonly local or in parameter forces the compiler to copy the struct defensively (because the method might mutate it). readonly struct removes that copy entirely, which matters for hot loops with large structs.

Rule of thumb: Mark structs readonly whenever all their state is set in the constructor and never mutated. Combine with in parameters and ref readonly returns for maximum zero-copy performance.

Records (C# 9+) are a special class or struct syntax that automatically generates value-based equality, a ToString() implementation, and deconstruction. By default, record (without struct) is a reference type but behaves with value semantics for equality.

// record class (C# 9) — reference type with value equality
record Person(string Name, int Age);

var a = new Person("Alice", 30);
var b = new Person("Alice", 30);
Console.WriteLine(a == b);      // True — value equality (generated)
Console.WriteLine(ReferenceEquals(a, b)); // False — different objects

// Non-destructive mutation with 'with':
var older = a with { Age = 31 }; // creates a new Person, copies other fields
Console.WriteLine(older); // Person { Name = Alice, Age = 31 }

// record struct (C# 10) — value type with value equality
record struct Point(int X, int Y);
var p1 = new Point(1, 2);
var p2 = new Point(1, 2);
Console.WriteLine(p1 == p2); // True

Records also generate a positional constructor and Deconstruct method from the primary constructor syntax. They are ideal for DTOs, commands, events, and any immutable data-carrying types.

Rule of thumb: Use record class for immutable reference types that need value equality (DTOs, domain events). Use record struct for small immutable value types. Use class when you need mutable state or inheritance beyond records.

String interning is a runtime optimisation where the CLR maintains a pool of unique string literals. When two string literals have the same content, the CLR makes them reference the same object, saving memory. This can cause surprising results when comparing strings with ReferenceEquals.

string a = "hello";
string b = "hello";
// Compile-time literal interning: a and b are the SAME object
Console.WriteLine(ReferenceEquals(a, b)); // True — interned

string c = new string(new[] { 'h','e','l','l','o' }); // runtime-constructed
Console.WriteLine(ReferenceEquals(a, c)); // False — different object

// Force interning of a runtime string:
string d = string.Intern(c);
Console.WriteLine(ReferenceEquals(a, d)); // True — d is now the interned version

// Check if a string is already interned without adding it:
string? existing = string.IsInterned("hello"); // returns the interned ref or null

String interning applies automatically to compile-time literals across all assemblies. Runtime strings (built from StringBuilder, read from DB, etc.) are NOT interned by default. Never compare strings with == expecting reference identity — always use == (which calls string.Equals) or .Equals().

Rule of thumb: Use == or .Equals() for string value comparison — never ReferenceEquals. Interning is a CLR detail, not a correctness tool. Only call string.Intern when you have profiled and confirmed it reduces memory for a large set of repeated strings.

More ways to practice

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

or
Join our WhatsApp Channel