Everything so far in this series has been about shaping code: generics, delegates, lambdas, LINQ, iterators, events. This post is about what happens underneath: what a variable physically holds, why assigning one variable to another sometimes copies the data and sometimes does not, and why a List<int> can quietly allocate a million objects if you use the wrong interface. It is the topic that explains the largest number of “wait, why did that happen” bugs.
One question: what is in the box?
Every type in C# is either a value type or a reference type, and the difference is a single question: when you declare a variable, does the variable contain the data, or an address where the data lives?
- Value type: the variable is the data.
int,double,bool,char,decimal,DateTime,Guid, everystruct, everyenum. - Reference type: the variable holds a reference to data elsewhere.
class,interface,string, arrays,delegate,record(unless declaredrecord struct).
Everything else in this post is a consequence of that one difference.
Assignment copies the box, not what it points to
// value type
int a = 5;
int b = a; // copies the 5
b = 10;
Console.WriteLine(a); // 5, a is untouched
// reference type
public class Book { public string Title { get; set; } public int Price { get; set; } }
var a = new Book { Title = "1984", Price = 15 };
var b = a; // copies the reference, NOT the book
b.Price = 99;
Console.WriteLine(a.Price); // 99, same book
Two variables, one book. b = a copied a memory address, so both names point at one object. This is the single most common source of accidental shared-state bugs in C#, and it happens far from the assignment: you pass a Book to a method, the method sets a property, and the caller’s object changed.
The struct version behaves completely differently:
public struct BookInfo { public int Id; public int Price; }
var a = new BookInfo { Id = 1, Price = 15 };
var b = a; // copies the whole struct
b.Price = 99;
Console.WriteLine(a.Price); // 15, untouched
Same code shape. Opposite result. The only thing that changed is the word struct.
Passing to a method is the same rule
Method arguments are assignments, so nothing new applies, but this is where the confusion peaks, so it is worth being explicit. C# passes everything by value by default. For a reference type, the reference is what gets passed by value.
static void Discount(Book book) => book.Price -= 5; // mutates the caller's object
static void Replace(Book book) => book = new Book(); // does NOT affect the caller
var b = new Book { Price = 15 };
Discount(b); Console.WriteLine(b.Price); // 10, the object was changed
Replace(b); Console.WriteLine(b.Price); // 10, the local variable was repointed, that's all
Discount followed the reference and changed the object the caller can also see. Replace overwrote its own private copy of the address; the caller’s variable still points where it always did. “Pass by reference” is the wrong phrase for this, and interviewers listen for it. The accurate phrasing is: the reference is passed by value.
To genuinely pass a variable by reference, say so:
static void Replace(ref Book book) => book = new Book { Price = 1 };
var b = new Book { Price = 15 };
Replace(ref b);
Console.WriteLine(b.Price); // 1, the caller's variable itself was reassigned
ref: the caller’s variable is passed itself. It must be initialised first, and the method may read and write it.out: same, but the caller need not initialise it and the method must assign it before returning. This is whatint.TryParse(s, out var n)uses.in: passed by reference, read-only. Used for large structs, to avoid copying without allowing mutation.
Stack and heap, with the caveat everyone omits
The usual line is “value types go on the stack, reference types go on the heap”. It is a useful first approximation and it is not accurate, so here is the version that survives follow-up questions.
- The stack is per-thread, LIFO, and fast. A method call pushes a frame with its locals; the frame is popped on return. No garbage collector involvement at all.
- The heap is shared, and the garbage collector manages it. Allocating is cheap; collecting is the cost, and it grows with the number and lifetime of objects.
new Book() allocates a Book on the heap and returns its address, which is stored in your local variable: the variable sits on the stack, the object does not.
The caveat: where a value type lives depends on where it is declared, not on being a value type.
class Order
{
public int Quantity; // an int living on the HEAP, inside the Order object
}
int[] numbers = new int[1000]; // 1000 ints, all on the heap, inside one array object
A value type declared as a field of a class lives inside that class’s heap allocation. A value type captured by a lambda moves into a compiler-generated class on the heap. A value type in an async method’s state machine lives on the heap. The honest rule: a value type is stored inline wherever it is declared. Sometimes that place happens to be the stack.
Why this matters practically: value types declared as locals cost the GC nothing, and a struct field inside a class costs no extra allocation, which is why an array of a million structs is one allocation and an array of a million class instances is a million and one.
Boxing, the hidden allocation
Reference types and value types must sometimes be interchangeable: object is the base of everything, and pre-generics APIs took object for everything. Reconciling the two is boxing: wrapping a value type in a heap object so a reference can point to it.
int n = 42;
object boxed = n; // boxing: allocates on the heap, copies 42 into it
int back = (int)boxed; // unboxing: copies the value back out
Boxing is an allocation plus a copy, and it happens implicitly, which is what makes it dangerous. Three common triggers:
object o = 42; // assigning to object
IComparable c = 42; // assigning to an interface
Console.WriteLine("Count: " + 42); // older string concatenation paths
The interface case is the subtle one and is the reason IEquatable<T> exists. A struct implementing an interface gets boxed when accessed through that interface, so a comparison inside a sort of a million structs can produce a million allocations.
This is also the historical argument for generics, which the first post in the series made from the type-safety angle. Here is the performance half:
var list = new ArrayList(); // pre-generics: stores object
list.Add(42); // boxes, heap allocation per item
var typed = new List<int>(); // generic: stores int directly
typed.Add(42); // no boxing, values stored inline
List<int> is backed by a genuine int[]. ArrayList holding a million ints allocates a million boxes. Generics did not just add compile-time safety, they removed a whole category of allocation.
Nulls, defaults and equality
Three more consequences of the same distinction.
Default values. An uninitialised reference type is null. An uninitialised value type is its zero: 0, false, '\0', or a struct with every field zeroed. There is no such thing as a null int, which is why Nullable<int>, spelled int?, exists: it is a struct wrapping an int plus a bool HasValue. That is why int? can be null but is still a value type, and why myInt.Value throws InvalidOperationException rather than NullReferenceException when it is empty.
Equality. By default, == on value types compares contents, and == on reference types compares identity: are these the same object?
new BookInfo { Id = 1 } == new BookInfo { Id = 1 }; // structs: compare fields (if == is defined)
new Book { Id = 1 }.Equals(new Book { Id = 1 }); // classes: false, different objects
string is the famous exception: it is a reference type, but == is overloaded to compare characters, because that is what everybody means. Records are the other exception, and they get their own post later in this series.
Copy semantics. Passing a struct to a method copies every field. That is free for an 8-byte struct and wasteful for a 200-byte one, which is why Microsoft’s guidance is that a struct should be small (roughly 16 bytes or less), immutable, and logically a single value.
Should you write a struct?
Mostly no. Use a class by default; reach for a struct when all of these hold:
- It logically represents one value, like a point, a money amount, or a date.
- It is small, around 16 bytes or less.
- It is immutable. Mutable structs are notorious:
list[0].Price = 5does not compile for a struct list precisely because you would be mutating a temporary copy, and readonly-field copies produce silent no-ops. - It is short-lived or allocated in bulk, where avoiding heap allocations actually shows up in a profiler.
DateTime, TimeSpan, Guid, decimal and int are all structs, and they all fit that description exactly. If your type has behaviour, identity, or wants inheritance, it is a class: structs cannot inherit from another struct or class, though they can implement interfaces.
The same idea inside Google, Apple and Netflix
Java (Netflix, Android). Java has the same divide with a much harder line: eight primitives are value types and everything else is a reference type. There is no struct, and no user-defined value types until Project Valhalla lands. Java’s boxing pain is worse and more visible: List<Integer> boxes every element because generics cannot hold primitives at all, so List<int> is not even expressible. C# avoided that by making generics reified at runtime: List<int> really is specialised for int.
List<Integer> nums = new ArrayList<>();
nums.add(42); // autoboxing, a heap object per number
Go (Google). Structs are the default and classes do not exist; you choose semantics per use with pointers:
func discount(b Book) { b.Price -= 5 } // copy, caller unaffected
func discount(b *Book) { b.Price -= 5 } // pointer, caller's value changed
Go makes the copy-vs-reference decision explicit at every call site, which is the same decision C# makes once, at type declaration.
Swift (Apple). Swift went further than C#: struct is the recommended default, and the entire standard library (String, Array, Dictionary) is value types, using copy-on-write so copying stays cheap until you mutate. Apple’s advice to prefer value types is the opposite of C#‘s advice, and the reason is thread safety: a value you copied cannot be changed by anyone else.
Rust. Same distinction taken to its conclusion: Copy types are copied, everything else is moved, and the compiler enforces at build time that two owners cannot mutate the same data. The C# bug at the top of this post, var b = a; then mutating through b, is a compile error in Rust by design.
JavaScript (Meta). Primitives copy, objects and arrays share, which produces the identical bug in React state:
const b = a; // same object
b.price = 99; // a.price is 99 too, and React may not re-render
Which is why every React tutorial insists on {...a, price: 99}. Same lesson, different language.
Summary
- A value type variable holds the data. A reference type variable holds an address of data elsewhere.
- Assignment copies the variable’s contents: the whole value for a struct, just the address for a class. Two references to one object is the default source of shared-mutation bugs.
- C# passes everything by value; for reference types, the reference is what is copied. Use
ref,outorinto pass the variable itself. - “Value types live on the stack” is a simplification. A value type is stored inline wherever it is declared, including inside heap objects, arrays, closures and async state machines.
- Boxing wraps a value type in a heap object, implicitly, when it is assigned to
objector accessed through an interface. It costs an allocation and a copy. - Generics eliminated the boxing that
ArrayListforced;List<int>really stores ints. - Value types default to zero and can never be null;
int?isNullable<int>, a struct with aHasValueflag. - Prefer classes. Choose a struct only for small, immutable, single-value types allocated in bulk.
Interview questions and answers
1. What is the difference between a value type and a reference type?
A value type variable contains its data directly; a reference type variable contains a reference to data stored elsewhere. That determines what assignment copies, what a method receives, whether the variable can be null, and how equality behaves by default.
2. Where are value types and reference types stored?
Reference type instances are always on the managed heap. Value types are stored inline wherever they are declared: as a local, that is usually the stack; as a field of a class, inside that class’s heap allocation; as an array element, inside the array on the heap; and if captured by a lambda or an async method, inside a compiler-generated heap object.
3. Is C# pass by value or pass by reference?
By value, by default, for both kinds of type. For a reference type, the value being copied is the reference, so the method can mutate the shared object but cannot change which object the caller’s variable points to. ref and out pass the variable itself, which is genuine pass by reference.
4. What is the difference between ref, out and in?
All three pass by reference. ref requires the argument to be initialised beforehand and allows read and write. out does not require initialisation but obliges the method to assign before returning. in passes read-only, used to avoid copying large structs while forbidding mutation.
5. What is boxing and unboxing, and why does it matter?
Boxing wraps a value type in a heap-allocated object so it can be treated as a reference; unboxing copies the value back out with a cast. It matters because it is implicit, assigning to object or to an interface does it silently, and each box is a heap allocation plus a copy, so it turns into GC pressure inside loops and comparison-heavy code.
6. How did generics reduce boxing?
Pre-generic collections such as ArrayList stored object, so every value type added was boxed. Because .NET generics are reified rather than erased, List<int> is specialised at runtime for int and stores values inline in an int[], so no boxing occurs.
7. Why can’t an int be null, and how does int? work?
Value types always contain a value and default to zero, so they have no representation for absence. int? is Nullable<int>, a struct containing an int and a bool HasValue. It is therefore still a value type; it is boxed as either null or a plain boxed int, and reading .Value when empty throws InvalidOperationException.
8. When would you write a struct instead of a class?
When the type represents a single small value, is immutable, and is created in large numbers where avoiding heap allocation is measurable, coordinates, money, identifiers. If it needs identity, inheritance, mutability or is larger than roughly 16 bytes, use a class, because copy costs and boxing will outweigh the allocation saved.
9. Why are mutable structs discouraged?
Because every copy is silent. Mutating a struct returned from a property, stored in a collection accessed by an interface, or held in a readonly field mutates a temporary copy and the change disappears with no error. Making the struct immutable removes the entire class of bug.
10. Why does == behave differently for string than for other classes?
string is a reference type, so by default == would compare references. It is explicitly overloaded to compare character sequences instead, because that is what callers virtually always want. Other reference types compare identity unless they override Equals and ==, which is what records do automatically.