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 generics — List<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 Fundamentals interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.