"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.
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?
Depending on what a lambda references, the compiler chooses one of three strategies:
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.this only — 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-existing this reference 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.
x => x * 2x => x * multiplierC#'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 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.
this / instance members, with no locals?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);
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
}
MakeMultiplier allocates a fresh display class instance to hold that call's own factor, plus a fresh delegate wrapping it. There's no caching possible here — each call legitimately needs its own independent copy of factor, so reusing one instance across calls would be incorrect, not just unoptimized.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.
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.
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.
this, the compiler doesn't need to invent any new storage at all — this already 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 existing this — no additional display-class object needed.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.
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.
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.
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.
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.
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.
<>c__DisplayClass0 — a question every developer eventually asks the first time they inspect generated IL.this → private instance method, no extra class needed.this (instance members, no locals) can become a plain private instance method on the enclosing type — no extra class required, since this already has a suitable lifetime.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?
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?
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?
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?
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.
dotnetmadeeasy.com — Learn C# and .NET, the right way.