"Value types live on the stack" is the most common oversimplification in all of C# — and it's time to replace it with the precise version.
Back in Foundations, you learned that value types are copied and reference types are shared — and you probably also picked up the shorthand "value types live on the stack, reference types live on the heap." That shorthand got you through two entire books of lessons just fine. But it's not actually what the C# language specification guarantees, and it starts producing genuinely wrong predictions the moment you introduce closures, iterators, or async methods.
Now that you understand how the heap actually works — generations, bump allocation, GC roots — you have everything you need to learn the precise rule, not the beginner-friendly approximation.
In this lesson, you'll learn exactly when a value type ends up on the stack versus the heap, why reference types are always heap-allocated regardless, and how to trace through a real method call to see exactly what lives where.
The stack and the heap are two different regions of memory, used for different purposes and reclaimed in completely different ways:
Here is the rule stated with full precision, correcting the Foundations-level shorthand:
The C# language specification and the ECMA CLI specification do not mandate exactly where a value type's storage lives. "Value types go on the stack" is a common implementation behavior of the mainstream .NET runtime for a specific, common case — not a language guarantee you can always rely on.
In practice, on the mainstream .NET runtime: a local value-type variable or parameter typically lives on the stack — unless it's captured by a closure, an iterator (yield return), or an async method's state machine, or it's a field of an object that is itself heap-allocated. In any of those cases, the value type's storage lives wherever its containing structure lives — which is the heap.
Reference types themselves are always heap-allocated, full stop — there's no equivalent exception. What can live on the stack for a reference type is only the reference itself (the pointer to the object) — never the object it points to.
If every single value — every int, every loop counter, every temporary calculation — had to be tracked and reclaimed by the GC the way heap objects are, the GC would have vastly more work to do, and even the cheapest, most transient values would pay collection overhead. Local values used only within a single method call, and gone the instant that method returns, don't need the GC's help at all — their lifetime is already perfectly, statically known: it's exactly the duration of the method call.
The stack exploits that known lifetime. Because it operates in strict last-in-first-out order — the most recently called method is always the first to return — reclaiming a stack frame's memory when its method returns is as simple as moving a pointer back, with zero tracing, zero reachability analysis, and zero GC involvement. This is even simpler and cheaper than Gen0's bump allocation from the previous lesson, because the stack doesn't even need a collection cycle — the moment of reclamation (method return) is known in advance, not discovered by tracing.
The catch: this only works when a value's lifetime truly matches its declaring method's call frame. The moment a value needs to outlive the method that created it — because a closure captured it, or an async method needs it to still exist after resuming from an await — strict LIFO stack discipline can no longer guarantee it's still valid. In those cases, the runtime has no choice but to give that value heap storage instead, so its lifetime can be governed by reachability, exactly like everything else on the heap.
StackOverflowExceptionpublic class Order
{
public int Id { get; set; }
public decimal Total { get; set; }
}
public static decimal ApplyDiscount(int quantity, decimal unitPrice)
{
decimal subtotal = quantity * unitPrice; // value type, local
var order = new Order { Id = 1, Total = subtotal }; // reference type
decimal discount = subtotal > 100m ? 0.1m : 0m; // value type, local
return subtotal - (subtotal * discount);
}
quantity, unitPrice — parameters, value types, live directly on the stacksubtotal, discount — local value types, live directly on the stackorder — the reference (pointer) to the Order object lives on the stackOrder object — its header, and its Id and Total fields (yes, even though Total is a decimal, a value type — it's a field of a heap object, so it lives inline, on the heap, as part of that object)quantity, unitPrice, subtotal, discount, and the order reference itself — is instantly reclaimed, no GC involved.Order object on the heap becomes unreachable (assuming nothing else references it) and awaits a future GC collection, exactly as covered in the last three lessons.Notice the key subtlety already visible here: subtotal (a local decimal) lives on the stack, but order.Total — also a decimal — lives on the heap, as part of the Order object. Same type, same kind of value, different storage — because one is a standalone local variable and the other is a field of a heap-allocated object.
public static Func<int> MakeCounter()
{
int count = 0; // looks like an ordinary local int...
// ...but this lambda captures 'count' by reference, and the
// lambda itself must outlive MakeCounter's call frame — so
// the compiler moves 'count' into a compiler-generated closure
// object, allocated on the HEAP, not the stack.
return () => ++count;
}
var counter = MakeCounter();
Console.WriteLine(counter()); // 1
Console.WriteLine(counter()); // 2 — 'count' is still alive, on the heap
Why this happens: count is an int — an ordinary value type, and by the beginner shorthand you'd expect it "on the stack." But the lambda expression captures it, and that lambda is returned from MakeCounter — meaning it needs to keep working after MakeCounter's stack frame is long gone. Strict stack LIFO discipline can't support that, so Roslyn generates a hidden class behind the scenes to hold count, allocates an instance of it on the heap, and both MakeCounter's local code and the returned lambda access count through that shared heap object. The variable didn't change type — it changed storage location, because its required lifetime changed.
This exact pattern happens constantly in ordinary ASP.NET Core and async code you've already been writing since the Intermediate tier:
public async Task<decimal> GetOrderTotalAsync(int orderId)
{
decimal total = 0m; // a value type...
var order = await _repository.GetOrderAsync(orderId); // suspension point!
// ...but 'total' has to survive the await — the method's execution
// is suspended and later resumed, possibly on a different point in
// the call stack entirely. The compiler-generated async state
// machine holds 'total' as a field of itself, and that state
// machine is heap-allocated, not stack-allocated.
total = order.Items.Sum(i => i.Price * i.Quantity);
return total;
}
Every async method that contains an await is compiled into a state machine — and any local variable (value type or not) that needs to stay alive across that await becomes a field of that state machine object, on the heap, for exactly the same underlying reason as the closure example: its required lifetime no longer matches a single, uninterrupted stack frame. You already used this mechanism throughout the Intermediate tier without necessarily connecting it to "where do my local variables actually live" — now you know precisely why.
The stack is like a sticky note you keep only as long as you're actively working on one specific task. The moment you finish that task and move to the next, you throw the sticky note away — no thought required, because you know precisely when it stops being useful: the instant the task ends.
The heap is like a filing cabinet — things go in it because someone, somewhere, might still need to look them up later, and you genuinely don't know in advance exactly when that need will end. A filing clerk (the GC) has to periodically check the cabinet and ask "is anyone still referencing this file?" before removing anything.
The closure and async cases in this lesson are exactly the moment a sticky note has to become a filed document: you wrote a number down intending to use it only for the current task, but then discovered the task itself needs that number to still exist after the current task is done — so it can no longer be a disposable sticky note. It has to go in the cabinet instead, where its lifetime can be tracked properly.
StackOverflowException — one practical, visible consequence of the stack's fixed size.async state machine.stackalloc and Span<T> — for advanced, performance-critical scenarios.The second half is true without exception — a reference type's object is always heap-allocated. The first half is the oversimplification this whole lesson exists to correct: a value type is typically stack-allocated for ordinary local variables and parameters, but is heap-allocated whenever it's a field of a heap object, when it's captured by a closure/iterator/async state machine, or when it's boxed (the next lesson's topic). "Typically" is doing real, load-bearing work in that sentence.
No — a value-type field is stored inline, as part of the containing object's own memory layout, wherever that containing object lives. If the containing object is on the heap (as every reference type is), the value-type field is on the heap too, physically embedded within that object — not off on the stack somewhere with a pointer connecting them. This is exactly why order.Total in the earlier example lived on the heap, right alongside order.Id, despite decimal being a value type.
Not the takeaway here. Closures, iterators, and async/await are essential, idiomatic C# tools you've been using productively since the Intermediate tier, and the heap allocation involved is typically small and, per the earlier lessons in this module, genuinely cheap. This lesson is about building an accurate mental model of memory, not discouraging language features that happen to require heap storage for their captured state.
Reasoning "this is a struct, so it never touches the heap" when designing a large, mutable class that embeds many struct fields, or when the struct is used as a field, array element, or is boxed elsewhere.
Track the value type's context, not just its declaration: a struct embedded as a class field lives on the heap as part of that class; a struct in an array element lives inline within that array's heap allocation. Only a genuinely standalone, non-captured local struct variable gets the pure stack-allocation benefit.
Not realizing that assigning a lambda to a delegate field, or returning it from a method, silently moves every variable that lambda captures onto the heap, with real (if small) allocation and object-header cost from the previous lesson.
Recognize that capturing local state in a closure has a real, if usually small, cost — this is worth being aware of specifically in hot loops that allocate many short-lived closures, even though it's rarely worth avoiding closures altogether elsewhere.
Saying "put it on the stack" as shorthand for "make it a struct" — the two are related but not equivalent, as this entire lesson demonstrates.
Use precise language: a value type's typical default behavior, for an uncaptured local, is stack allocation — but the actual storage location always depends on context, not on the type category alone.
You don't choose stack-vs-heap directly in ordinary C# — the compiler and JIT decide, based on the rules in this lesson. What changes is how accurately you can reason about memory:
async method with local state that survives an await involves heap allocation for its state machine — a fact worth knowing when reasoning about allocation-heavy hot paths involving async code.StackOverflowException happens (a fixed-size, per-thread stack, exhausted by deep recursion) versus an OutOfMemoryException (the much larger, GC-managed heap being exhausted) — two genuinely different failure modes with different causes.stackalloc and Span<T> offer explicit, deliberate stack allocation for advanced scenarios — a full topic reserved for a dedicated lesson later in this book.You've replaced the beginner shorthand with the precise rule. Let's confirm you can apply it correctly.
1. Which statement most accurately reflects what the C# language actually guarantees about value type storage?
Correct: B
Why B is correct: Neither the C# language spec nor the underlying CLI spec mandates a physical storage location for value types — they define value-type semantics (copy behavior, no shared identity). Stack allocation for the common case is real, observable mainstream runtime behavior, but it's an implementation detail, not a contractual guarantee.
Why A is incorrect: This is exactly the oversimplified shorthand the lesson corrects — it breaks down for captured, boxed, or field-embedded value types.
Why C is incorrect: This overcorrects in the opposite direction — value types genuinely do live on the stack in the common, uncaptured local-variable case; they aren't always heap-allocated.
Why D is incorrect: Built-in types like int are themselves value types (an alias for System.Int32, a struct) and follow exactly the same rules as any other value type — there's no special carve-out for them.
Reinforcement: "Typically, not guaranteed" is the precise, defensible framing this entire lesson is built to establish.
2. A class has a public decimal Price { get; set; } auto-property. Where does the actual decimal value live once an instance of that class is created?
Correct: B
Why B is correct: A value-type field is stored inline within its containing object's memory. Since the containing object is a class instance (a reference type, always heap-allocated), the decimal field physically lives on the heap as part of that object — exactly like the order.Total example in the lesson.
Why A is incorrect: Being a value type doesn't override where its containing structure lives — this is precisely the Common Confusion the lesson called out: field storage follows the container, not the type category alone.
Why C is incorrect: There's no such splitting mechanism for individual values — a field's bytes are stored contiguously as part of one storage location, not divided across memory regions.
Why D is incorrect: There's no separate heap region reserved specifically for value-type fields — they live in the same generational heap as everything else, embedded within their containing object.
Reinforcement: "Follow the container" is the rule — a value type's storage location is determined by where the thing holding it lives, not by the value type's own category.
3. A method declares a local int variable and then assigns a lambda expression that reads that variable to a class-level event handler, meaning the lambda will keep running long after the method returns. What happens to that int variable's storage?
Correct: B
Why B is correct: Because the lambda needs to keep reading this variable after the declaring method's stack frame is gone, Roslyn rewrites it into a field of a compiler-generated closure class, an instance of which is heap-allocated — exactly the MakeCounter pattern walked through in the Simple Example.
Why A is incorrect: The whole reason this mechanism exists is to prevent exactly this outcome — stack memory being read after its frame is gone would be a serious correctness bug, which is precisely what closure-to-heap conversion avoids.
Why C is incorrect: Capturing local variables in lambdas assigned to event handlers is an extremely common, fully supported C# pattern — no compile error occurs.
Why D is incorrect: The variable becomes a field of a per-invocation closure instance, not a shared static field — each call to the method that creates a new closure gets its own independent captured variable.
Reinforcement: Any time a lambda needs to outlive its declaring method — event handlers being a very common real-world case — its captured variables are silently, safely relocated to the heap.
4. Why does an async method's local variable that is read both before and after an await need heap allocation, even if it's an ordinary value type like int?
Correct: B
Why B is correct: An await can suspend and later resume execution, which breaks the assumption of one continuous, uninterrupted stack frame that ordinary stack allocation depends on. The compiler-generated async state machine holds such variables as its own fields, and that state machine object is heap-allocated so it can persist correctly across the suspension.
Why A is incorrect: The variable's declared type doesn't change — an int stays an int; what changes is that it's now stored as a field of a heap-allocated state machine object rather than as plain stack storage.
Why C is incorrect: This isn't a security mechanism at all — it's purely a consequence of needing to preserve state correctly across a suspension point, exactly like the closure case.
Why D is incorrect: This directly contradicts the lesson's real-world example — async methods with state that crosses an await boundary do involve heap allocation for their state machine.
Reinforcement: This is the same underlying principle as closures, applied to async/await: whenever a value's required lifetime outlives one continuous stack frame, it has to move to the heap.
5. A StackOverflowException and an OutOfMemoryException are both memory-related failures in a .NET application, but they have different root causes. Based on this lesson, what's the key distinction?
Correct: B
Why B is correct: These correspond directly to the two distinct memory regions covered in this lesson — the fixed-size, per-thread stack (exhausted by excessive nested call frames, most commonly runaway recursion) versus the much larger, dynamically-growing, GC-managed heap (exhausted when there's genuinely no more room to allocate, even after collection).
Why A is incorrect: They are distinct exception types representing exhaustion of two entirely different memory regions with different characteristics (fixed small size vs. large and dynamic).
Why C is incorrect: Neither exception is tied to a specific type category — stack overflow is about call-frame depth, and out-of-memory is about total heap capacity; both can involve any mix of value and reference types.
Why D is incorrect: While LOH exhaustion could contribute to an OutOfMemoryException, it's not the exclusive cause — general heap exhaustion from any generation can trigger it, and it has nothing to do with StackOverflowException at all.
Reinforcement: Correctly distinguishing these two failure modes — and knowing which memory region each corresponds to — is a direct, practical payoff of understanding the stack/heap distinction precisely.
You've replaced the beginner shorthand with the precise, defensible rule — next, you'll see the specific mechanism (boxing) that deliberately moves a value type onto the heap.
dotnetmadeeasy.com — Learn C# and .NET, the right way.