← Open in the full interactive course (progress tracking, search & more)

"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:

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

Capturing Lambda

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 key insight

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

THE COMPILER'S DECISION, AS A CHECKLIST
You write a lambda
Does it read any local variable or parameter from the enclosing scope?
NO
→ private static method, cached singleton delegate
YES
Does it capture ONLY this / instance members, with no locals?
YES → private instance method on the enclosing type, no new class
NO (locals captured) → new display class, instantiated per activation

How It Works

CASE 1 — A CAPTURE-FREE LAMBDA, DECOMPILED CONCEPTUALLY
YOUR CODE
Func<int, int> doubler = x => x * 2;
ROUGHLY WHAT THE COMPILER GENERATES
// 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);
CASE 2 — A CAPTURING LAMBDA, DECOMPILED CONCEPTUALLY
YOUR CODE
Func<int, int> MakeMultiplier(int factor)
{
    return x => x * factor; // captures 'factor'
}
ROUGHLY WHAT THE COMPILER GENERATES
// 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
}

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 Laminated Sign vs. a Custom Backpack

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

CONNECTING BACK TO ADVANCED PART I
1. A DISPLAY CLASS IS AN ORDINARY HEAP OBJECT, NOTHING MORE
2. WHEN A CAPTURED VARIABLE OUTLIVES ITS ORIGINAL STACK FRAME
3. WHY this-ONLY CAPTURE IS CHEAPER THAN LOCAL CAPTURE
4. THE ANSWERABLE QUESTION, IN PRACTICE

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?

Mental Model

Captures nothing → private static method + one cached delegate, forever.
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


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.


dotnetmadeeasy.com — Learn C# and .NET, the right way.