How C# Lambdas Compile: Allocations and Closures Inside
"Does this lambda allocate?" is not a mystery — it's a question with a precise, mechanical answer once you know what the compiler actually generates for it.
You've written hundreds of lambdas by now — x => x * 2, o => o.Total > 100, () => Console.WriteLine("done"). Every single one of them looks like the same kind of thing: a small, self-contained expression with an arrow. But from the compiler's point of view, they are not remotely the same. Some compile to a private static method with a single, permanently cached delegate instance — created once, reused forever, allocating nothing on the millionth call. Others compile to something much heavier: a hidden class, instantiated fresh every time the surrounding method runs.
You already know from Advanced Part I (lessons 169–176) exactly how the GC and Gen0 allocation work — that heap allocation is cheap in .NET, but never free, and that it's the compacting generational collector, not magic, that keeps "cheap" true. This lesson connects that knowledge directly to lambdas: which kind you're writing, in any given case, and exactly why.
By the end, "does this lambda allocate?" stops being a guess you'd need a profiler to answer and becomes something you can determine just by reading the lambda itself.
What Is It?
The Simple Explanation
A lambda expression isn't a runtime feature at all — it's pure compile-time syntax. By the time your program is running, the lambda you wrote no longer exists as a lambda; the C# compiler (Roslyn) has already rewritten it into one of a small number of ordinary, boring C# constructs it knows how to generate: a method, sometimes a whole extra class, and a delegate value pointing at one of them. Which specific shape it becomes depends entirely on one question: does the lambda need to read any state from outside its own parameters?
The Technical Definition — Three Compilation Strategies
Depending on what a lambda references, the compiler chooses one of three strategies:
- No captures at all — the lambda only uses its own parameters and static members. The compiler emits a private static method, and — critically — generates code to build the delegate wrapping it once, caching that single instance in a compiler-generated static field for reuse on every subsequent evaluation.
- Captures local variables (but not
this) — the lambda reads a local variable, or a parameter, from its enclosing method. The compiler generates a hidden "display class" (informally, a closure class) with a field for each captured variable, turns the lambda into an instance method on that class, and allocates a fresh instance of the display class each time the enclosing method runs and needs it. - Captures
thisonly — the lambda reads instance members (fields, properties) of the containing object, but no local variables. The compiler can often turn the lambda into a private instance method directly on the enclosing type itself — no separate display class is needed, because the already-existingthisreference is all the captured state the lambda requires.
None of this is a hidden implementation detail Microsoft keeps quiet about — it's documented, standard C# compiler (Roslyn) behavior, visible the moment you decompile a build's IL.
Capture-Free Lambda
x => x * 2- Becomes: a private static method
- Delegate instance: created once, cached, reused forever
- Allocation per call: none, after the first
Capturing Lambda
x => x * multiplier- Becomes: an instance method on a compiler-generated display class
- Delegate instance: a new one each time it's created
- Allocation per creation: display class object + delegate object
Why Does It Exist?
The Problem — A Lambda Needs Somewhere to Keep Its Captured State
C#'s IL has methods, but a method by itself has nowhere to durably store a captured variable between calls — a method's local variables live on the stack and disappear the instant it returns. Yet a lambda like x => x > threshold, once created inside some method, needs to keep working correctly even after that enclosing method has returned and its stack frame is long gone. Something has to own threshold's value for as long as the lambda (and any delegate pointing at it) is still alive.
The Solution — Promote the Captured State to an Object on the Heap
The compiler's answer is exactly the same technique you already know from every other class in C#: put the state in fields on an object, and objects on the managed heap outlive the method that created them — that's precisely what the GC and generational collection from Advanced Part I exist to manage safely. A "display class" is nothing exotic; it's an ordinary, compiler-authored class, with ordinary fields, subject to the exact same Gen0 bump-pointer allocation and eventual collection as any object you'd write yourself.
But that machinery is only needed when there's actually captured state to hold. When a lambda captures nothing, there's nothing to keep alive beyond the method's lifetime — so the compiler skips the display class (and the per-call allocation) entirely, and goes one step further: since the resulting delegate is always functionally identical no matter how many times it's built, it only needs to be built once, ever.
The compiler isn't being clever for its own sake — it's applying the minimum machinery each specific lambda actually requires. A capture-free lambda needs no storage beyond its parameters, so it gets none. A capturing lambda needs somewhere durable to hold what it captured, so — and only so — it gets a heap-allocated object to hold it.
Big Picture
this / instance members, with no locals?How It Works
Func<int, int> doubler = x => x * 2;
// Compiler-generated, elsewhere in the class:
private static Func<int, int>? _cachedDoubler;
private static int Doubler_Method(int x) => x * 2;
// At the call site, roughly:
Func<int, int> doubler = _cachedDoubler ??= new Func<int, int>(Doubler_Method);
- The first time this line runs, the cached field is empty, so the delegate is built and stashed. Every later time this exact line runs — even on a completely different call to the enclosing method — the cached instance is reused. No new delegate, no new anything, gets allocated after the first time.
Func<int, int> MakeMultiplier(int factor)
{
return x => x * factor; // captures 'factor'
}
// Compiler-generated hidden "display class":
private sealed class <>c__DisplayClass0
{
public int factor;
public int Lambda_Method(int x) => x * this.factor;
}
Func<int, int> MakeMultiplier(int factor)
{
var displayClass = new <>c__DisplayClass0(); // NEW allocation, every call
displayClass.factor = factor;
return new Func<int, int>(displayClass.Lambda_Method); // NEW delegate too
}
- Every call to
MakeMultiplierallocates a fresh display class instance to hold that call's ownfactor, plus a fresh delegate wrapping it. There's no caching possible here — each call legitimately needs its own independent copy offactor, so reusing one instance across calls would be incorrect, not just unoptimized.
Simple Example
using System;
class Program
{
// No captures — compiled once, delegate cached and reused
static Func<int, bool> GetIsEvenCheck() => x => x % 2 == 0;
// Captures 'minimum' — a fresh display class + delegate every call
static Func<int, bool> GetIsAtLeast(int minimum) => x => x >= minimum;
static void Main()
{
var check1 = GetIsEvenCheck();
var check2 = GetIsEvenCheck();
Console.WriteLine(ReferenceEquals(check1, check2)); // True — SAME cached delegate instance, both calls
var atLeast5 = GetIsAtLeast(5);
var atLeast10 = GetIsAtLeast(10);
Console.WriteLine(ReferenceEquals(atLeast5, atLeast10)); // False — two DIFFERENT delegate instances,
// each wrapping its own captured 'minimum'
}
}
Code → Meaning → Result: ReferenceEquals makes the compiler's two strategies directly observable. GetIsEvenCheck's lambda captures nothing, so both calls hand back the literal same cached object — nothing new was allocated the second time. GetIsAtLeast's lambda captures minimum, which is different on each call, so it necessarily gets its own fresh display class and delegate every time — there's no way to share an instance when the captured value itself differs per call.
Real-World Example
An order-processing service filters a large in-memory batch of orders on every request. One version accidentally captures a per-request variable it doesn't need to; the fixed version doesn't:
using System;
using System.Collections.Generic;
using System.Linq;
public record Order(int Id, decimal Total, bool IsCancelled);
public class OrderReportService
{
// Captures 'requestId' even though it's never used inside the lambda body —
// a common accident when a lambda is copy-pasted from a context that DID need it.
public List<Order> GetActiveOrders_Wasteful(List<Order> orders, string requestId)
{
Console.WriteLine($"[{requestId}] Filtering orders...");
// This lambda captures nothing from the enclosing scope — 'o' is its own parameter —
// so despite appearances, THIS particular line is actually fine on its own.
return orders.Where(o => !o.IsCancelled).ToList();
}
// A version that DOES need per-call state genuinely has no choice but to capture it —
// the point isn't "never capture," it's "know when you unavoidably must."
public List<Order> GetOrdersAbove(List<Order> orders, decimal minimumTotal)
{
// 'minimumTotal' varies per call, so this lambda legitimately captures it —
// a fresh display class here is the necessary cost of parameterized filtering.
return orders.Where(o => o.Total >= minimumTotal).ToList();
}
// Genuinely capture-free — reuses one cached delegate across every call, forever.
public List<Order> GetCancelledOrders(List<Order> orders)
{
return orders.Where(o => o.IsCancelled).ToList();
}
}
GetOrdersAbove's allocation is unavoidable and entirely reasonable — minimumTotal genuinely differs per call, so a fresh closure is the correct price for that flexibility. GetCancelledOrders, by contrast, costs nothing beyond its very first invocation across the whole application's lifetime, precisely because its lambda needs no per-call state at all. Recognizing which of your own lambdas fall into which category — not eliminating captures altogether — is the actual skill this lesson is building.
Analogy
A capture-free lambda is like a laminated instruction sign bolted to a wall: it was made once, it never needs to change, and everyone who walks past reads the exact same physical sign — nobody needs to print a new one. That's the cached, reused delegate.
A capturing lambda is like packing a custom backpack for a specific trip: the trip's details (the captured variables) are different every time, so you can't reuse yesterday's already-packed bag — you genuinely need a new backpack, packed with today's specific contents, every single time you set out. That's the fresh display class, allocated per activation, holding exactly the captured values that call needs.
Under the Hood
- It has no special runtime privileges. It's allocated in Gen0 exactly like every other short-lived object — the bump-pointer allocation from lesson 174 applies to it unchanged.
- If the delegate wrapping it doesn't escape the enclosing method's scope (isn't stored anywhere longer-lived, isn't returned), it typically becomes garbage almost immediately and gets reclaimed cheaply on the next Gen0 collection, just like any other short-lived temporary object from lesson 173's generational model.
- Ordinarily, a local variable inside a value-type-only method could live on the stack (lesson 175). But a captured local has to survive after the enclosing method returns, since the returned delegate might still be called later — so the compiler can't leave it on the stack at all.
- Moving it into a display class field is exactly how the compiler solves this: the display class is a heap object, and heap objects aren't tied to any particular stack frame's lifetime — this is precisely why capturing forces a heap allocation even for what would otherwise be a simple value-type local.
- When a lambda only reads instance fields/properties through
this, the compiler doesn't need to invent any new storage at all —thisalready exists as a heap object with a stable lifetime tied to the enclosing instance. The lambda can simply become another private instance method on that same type, invoked via a delegate whose target is the existingthis— no additional display-class object needed. - This is why "a lambda inside an instance method that only touches the object's own fields" is meaningfully cheaper than "a lambda that captures a local variable," even though both feel like they're "capturing something" from a beginner's point of view.
- "Does this lambda allocate?" reduces to: "does it reference anything other than its own parameters and, at most, the enclosing instance's own members?" If yes to any local/outer variable, expect a per-activation allocation. If no, expect it to cost nothing beyond the very first time it's built.
- This is a mechanical, decompiler-verifiable fact about your code — not a rule of thumb, and not something that requires guessing or profiling to determine ahead of time.
Common Confusion
1. "A lambda's own parameter counts as a capture" — it doesn't
In x => x * 2, x is the lambda's own parameter, supplied fresh on every invocation — it is never "captured" from anywhere. Only variables that exist outside the lambda, in its enclosing scope, count as captures. This distinction is exactly what separates the two cases in the Simple Example above.
2. "Static caching means the lambda's logic can't change" — the logic never changes anyway, for a capture-free lambda
Caching is only possible, and only correct, because a capture-free lambda produces the exact same result for the exact same input, every single time it's built — there's no per-call state that could make two "instances" of it behave differently. If you find yourself wanting a lambda's behavior to vary between calls, that need for variation is itself what would force it to capture something, which in turn is what would disqualify it from caching.
3. "Every lambda inside a loop reallocates every iteration" — only true if it captures a per-iteration variable
A capture-free lambda written inside a loop body still only gets built (and cached) once, the first time that code path runs — the loop iterating doesn't force repeated allocation on its own. It's specifically capturing something that changes per iteration (like a loop variable used inside the lambda) that forces a fresh display class on every pass.
Common Mistakes
Mistake 1 — Capturing a variable the lambda doesn't actually need
Writing a lambda inside a method with several local variables in scope, and accidentally referencing one of them (say, for a debug Console.WriteLine left in during development) that isn't otherwise needed — turning what could have been a cheap, cacheable lambda into a display-class-allocating one.
Keep lambdas referencing only what they genuinely need. If a lambda that looks like it should be capture-free is unexpectedly allocating on every call, check exactly what it's referencing from the enclosing scope — often it's an easy trim.
Mistake 2 — Assuming the caching optimization applies to lambdas that must, by their nature, capture something
Expecting GetOrdersAbove(orders, 50m) and GetOrdersAbove(orders, 200m) from the real-world example above to somehow share one cached delegate — they logically can't, since each needs its own minimumTotal.
Accept that allocation as the legitimate, necessary cost of parameterized behavior — the goal from this lesson is recognizing which lambdas are avoidably capturing, not eliminating every capture unconditionally.
Mistake 3 — Over-optimizing low-frequency code for lambda allocation
Restructuring a rarely-called method (a one-time startup configuration step, a button-click handler) purely to avoid a capturing lambda's allocation, at the cost of readability.
This distinction matters most in hot paths — tight loops, per-request code in high-throughput services, LINQ over large in-memory sequences called frequently. For code that runs occasionally, a display-class allocation is genuinely negligible; prioritize clarity there, exactly as lesson 174 already established for allocation in general.
When Should I Use It?
- Reach for this knowledge specifically when profiling shows unexpected allocation pressure from delegate-heavy code (LINQ chains, callback-heavy APIs) in a hot path — knowing the two compilation strategies tells you exactly where to look.
- Prefer capture-free lambdas (or plain method groups) over capturing ones in genuinely hot loops, when the logic doesn't actually need per-call state — a small, free win once you know to look for it.
- Use this to explain, precisely, why a decompiled build shows classes named things like
<>c__DisplayClass0— a question every developer eventually asks the first time they inspect generated IL. - Don't restructure ordinary, low-frequency code purely to dodge closure allocation — as with all allocation concerns from Advanced Part I, correctness and readability come first outside of measured hot paths.
Mental Model
Captures locals → hidden display class + fresh delegate, every activation.
Captures only
this → private instance method, no extra class needed."Does this lambda allocate?" = "does it reference anything beyond its own parameters?"
Key Takeaway
- A lambda with no captured state compiles to a private static method, and the compiler caches a single delegate instance for it — built once, reused on every future evaluation, with no allocation after the first.
- A lambda that captures local variables compiles to an instance method on a compiler-generated "display class," which is allocated fresh each time the enclosing method runs and needs it.
- A lambda that captures only
this(instance members, no locals) can become a plain private instance method on the enclosing type — no extra class required, sincethisalready has a suitable lifetime. - This is ordinary, documented Roslyn compiler behavior, directly connected to Gen0 bump-pointer allocation (lesson 174) and generational collection (lesson 173) — a display class is just an ordinary short-lived heap object, nothing more exotic.
- "Does this lambda allocate?" is answerable by inspection: does it reference anything beyond its own parameters (and, at most, the enclosing instance)? If not, it's free after the first build; if so, expect a per-activation allocation.
Check Your Understanding
You've seen exactly what the compiler generates for a lambda in each of the three cases. Let's confirm you can tell them apart on sight.
1. Func<int, int> square = x => x * x; is created inside a method called a million times. How many delegate instances does this line allocate in total, across all million calls?
Show answer
Correct: B
Why B is correct: x => x * x references only its own parameter x — no captures. The compiler generates a private static method and a single cached delegate field, reusing that one instance on every evaluation of this line.
Why A is incorrect: That would be true if the lambda captured a variable that differed per call — this one captures nothing, so caching applies.
Why C is incorrect: At least one delegate object is always created — the first time this line executes — even for a capture-free lambda; it's just not recreated on every subsequent evaluation.
Why D is incorrect: Delegate creation happens when the lambda expression is evaluated (this line), not when the resulting delegate is later invoked — invoking square(n) doesn't create new delegates at all.
Reinforcement: "Created" (the lambda expression evaluating) and "invoked" (calling the resulting delegate) are two separate events — only the former can trigger allocation.
2. Func<int, bool> MakeCheck(int limit) => x => x > limit; is called 100 times with different values of limit. What does the compiler generate for the lambda inside it?
Show answer
Correct: B
Why B is correct: limit is a parameter of the enclosing method MakeCheck, referenced from inside the lambda — that's a capture. Because each of the 100 calls has its own distinct value of limit, each call needs its own display class instance to hold it, and therefore its own delegate.
Why A is incorrect: Caching only applies when there's nothing call-specific to preserve — here, each call's limit is genuinely different and must be kept separate.
Why C is incorrect: Lambdas referencing an enclosing method's parameters is one of the most common and entirely valid forms of capturing — this compiles correctly.
Why D is incorrect: A single static field could only hold one value at a time, which would be wrong the moment two calls with different limit values were in play simultaneously — each call needs its own separately-held value, which is exactly what a per-call display class instance provides.
Reinforcement: Capturing a parameter or local that legitimately varies per call is precisely the case where per-activation allocation is both expected and necessary.
3. Inside an instance method, a lambda reads only this._threshold (an instance field) and its own parameter — no local variables from the enclosing method. What does the compiler typically generate?
Show answer
Correct: C
Why C is correct: Reading only instance members through this — with no local-variable captures — lets the compiler turn the lambda into another private instance method on the same type, using the already-existing this as the delegate's target. No new class needs to be generated to hold anything.
Why A is incorrect: A separate display class is specifically needed to hold captured locals, which don't otherwise have a long enough lifetime — this doesn't have that problem, since the object it refers to already persists independently.
Why B is incorrect: Accessing this._threshold does depend on the specific instance, so it can't become a static method — it must be an instance method so it has a this to read the field from.
Why D is incorrect: Nothing about referencing instance state forces expression-tree compilation — that only happens when the lambda's target type is explicitly Expression<TDelegate>, as covered in the previous lesson.
Reinforcement: Capturing this alone is the cheapest form of capture — it reuses the existing object instead of allocating a new one.
4. Why does capturing a local variable force the compiler to move it into a heap-allocated display class, instead of leaving it on the stack where an ordinary local would normally live?
Show answer
Correct: B
Why B is correct: The delegate wrapping the lambda can outlive the method call that created it — it might be stored, returned, or invoked much later. A stack frame is gone the moment its method returns, so anything the lambda still needs to read has to live somewhere with a longer lifetime: a heap-allocated object.
Why A is incorrect: Relative access speed isn't the reason at all — the real constraint is lifetime, not performance of the access itself.
Why C is incorrect: The actual limitation isn't about generating IL to read a value — it's that the original stack storage simply won't exist anymore by the time a later invocation happens.
Why D is incorrect: As shown in Case 1's example, a capture-free lambda needs no display class at all — they're generated specifically to solve the captured-variable-lifetime problem, not as a universal requirement.
Reinforcement: Heap allocation for a captured variable exists to solve a lifetime problem, not a performance or syntax one.
You now know exactly what your compiler does with every lambda you write, and can answer "does this allocate?" by inspection alone — closing the loop between the GC/allocation lessons from Advanced Part I and everyday delegate code.