What happens when a lambda reaches outside itself and grabs a variable from where it was born — and why that variable is a live reference, not a snapshot.
Every lambda so far has been self-contained — its only inputs were its own parameters. But what happens when a lambda uses a variable that isn't one of its parameters, one that belongs to the method around it?
int threshold = 100;
Func<int, bool> isExpensive = price => price > threshold; // uses "threshold" — not a parameter!
threshold isn't a parameter of the lambda — it's a local variable from the surrounding method. Somehow, the lambda can still see it, even later, even after the method that declared threshold has already returned. This is a closure, and understanding exactly how it works — including one classic trap — is essential before you touch LINQ.
A closure is a lambda (or anonymous method) that "closes over" — captures and remembers — one or more variables from the scope where it was created, so it can keep using them even after that scope has technically finished running.
When a lambda references a variable declared outside its own parameter list, that variable is called a captured variable. The lambda, together with the captured variables it needs, forms a closure — the lambda's code plus the environment it depends on, bundled together so it keeps working no matter where the resulting delegate ends up being called from.
A callback rarely operates in total isolation. A validation rule needs a configured threshold. A retry handler needs a counter to track how many attempts have happened. An event handler needs to know which order it belongs to. If lambdas could only see their own parameters, you'd be forced to smuggle every bit of outside context through extra parameters or global state — clumsy, and it defeats the whole point of writing small, inline, self-explanatory logic.
C# lets a lambda simply reference any variable that's in scope where it's written — a local variable, a method parameter, even this inside an instance method — and the compiler automatically arranges for that variable to survive as long as the lambda (and any delegate built from it) survives. You don't write any special syntax to opt in; it happens the instant you reference an outer variable inside a lambda body.
Closures are what make lambdas genuinely useful as callbacks and deferred logic — logic you hand off to run later, possibly much later, possibly on a different call stack entirely. Without closures, a huge fraction of the delegate-passing patterns from lessons 098–100 wouldn't work.
int threshold = 100; — an ordinary local variableprice => price > threshold — threshold is not a parameter, so the compiler must capture itthreshold, bundled together, keep working even after the enclosing method returnsthreshold at the moment it's written — it keeps a live link to the actual variable.int count = 0;
Action increment = () => count++;
increment();
increment();
Console.WriteLine(count); // 2 — the outer variable itself changed
count were captured "by value," this would print 0. It doesn't — closures capture the variable's storage location, not a copy.using System;
class Program
{
static Func<int> CreateCounter()
{
int count = 0; // local to CreateCounter
return () => ++count; // captures "count" — closure formed
} // CreateCounter's stack frame ends here...
// ...but "count" lives on inside the closure
static void Main()
{
Func<int> counter1 = CreateCounter();
Func<int> counter2 = CreateCounter(); // a completely separate "count"
Console.WriteLine(counter1()); // 1
Console.WriteLine(counter1()); // 2
Console.WriteLine(counter1()); // 3
Console.WriteLine(counter2()); // 1 — counter2 has its own captured "count"
}
}
Code → Meaning → Result: count is a local variable inside CreateCounter, yet it keeps incrementing across calls to counter1() long after CreateCounter has returned. Each call to CreateCounter creates a fresh count, so counter1 and counter2 track entirely independent state.
A retry helper (like Retrier.RunWithRetry from lesson 098) is a natural place for closures: the lambda passed as operation often needs to reach outside itself to report progress or use configuration from the calling method.
using System;
class Program
{
static void ProcessOrder(string orderId, int maxAttempts)
{
int attemptsUsed = 0; // captured below — the caller wants to know this afterward
Retrier.RunWithRetry(
operation: () =>
{
attemptsUsed++; // mutating the captured variable
Console.WriteLine($"Processing order {orderId}, attempt {attemptsUsed}");
if (attemptsUsed < 3)
throw new InvalidOperationException("Simulated transient failure");
},
maxAttempts: maxAttempts,
onAttemptFailed: msg => Console.WriteLine($"[{orderId}] {msg}") // captures "orderId" too
);
Console.WriteLine($"Order {orderId} succeeded after {attemptsUsed} attempt(s).");
}
static void Main() => ProcessOrder("ORD-4471", maxAttempts: 5);
}
Both lambdas here — operation and onAttemptFailed — capture variables from ProcessOrder's scope (orderId, attemptsUsed). Because attemptsUsed is captured by reference, the final Console.WriteLine after RunWithRetry returns sees the true, updated attempt count — even though the increments happened inside a lambda passed several calls deep.
Because closures capture the variable itself, not its value at closure-creation time, capturing a loop counter is where beginners get burned. The behavior depends entirely on which kind of loop you use — and modern C# has changed this for the better.
for Loop — Still a Real Trapi exists for the entire loopi has already reached its final valueforeach Loop — Safe Since C# 5using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// ── The trap: classic `for` loop, capturing the loop variable directly ──
var brokenActions = new List<Action>();
for (int i = 0; i < 3; i++)
{
brokenActions.Add(() => Console.WriteLine($"for-loop lambda sees i = {i}"));
}
foreach (var action in brokenActions) action();
// for-loop lambda sees i = 3
// for-loop lambda sees i = 3
// for-loop lambda sees i = 3
// ^ All three captured the SAME "i" — by the time they run, the loop already finished at i == 3.
Console.WriteLine();
// ── The fix: copy the loop variable into a fresh local INSIDE the loop body ──
var fixedActions = new List<Action>();
for (int i = 0; i < 3; i++)
{
int local = i; // a brand-new variable, created fresh on every iteration
fixedActions.Add(() => Console.WriteLine($"fixed for-loop lambda sees local = {local}"));
}
foreach (var action in fixedActions) action();
// fixed for-loop lambda sees local = 0
// fixed for-loop lambda sees local = 1
// fixed for-loop lambda sees local = 2
Console.WriteLine();
// ── foreach: safe by default, no workaround needed ──
var foreachActions = new List<Action>();
foreach (int n in new[] { 0, 1, 2 })
{
foreachActions.Add(() => Console.WriteLine($"foreach lambda sees n = {n}"));
}
foreach (var action in foreachActions) action();
// foreach lambda sees n = 0
// foreach lambda sees n = 1
// foreach lambda sees n = 2
// ^ Correct out of the box — each iteration of a foreach gets its own "n" since C# 5.
}
}
Be precise about this: it's not that "loops and closures don't mix." It's that a classic for loop declares exactly one loop variable and mutates it every iteration, so every lambda created inside that loop shares that one variable. A foreach loop, since C# 5 (2012), instead creates a new iteration variable on every pass — so capturing it "just works" the way most people intuitively expect. Older C# tutorials (and .NET Framework code predating C# 5) may still describe foreach as having the same trap — that was true before C# 5, but is no longer accurate for any C# version you'll write today.
Capturing a variable is like wiring a lambda to a live security camera feed of that variable's location, not handing it a printed photograph. A photograph (capture by value) freezes what the variable looked like at that instant, forever. A live feed (capture by reference) shows whatever is happening at that location right now — including changes made after the photo would have been taken. That's exactly why the broken for-loop example above prints 3 three times: every lambda is watching the same live feed of i, and by the time anyone checks the feed, the loop has already moved i to its final value.
When the compiler detects that a lambda captures one or more outer variables, it can no longer store those variables as ordinary stack-allocated locals — a stack frame disappears once its method returns, but the closure might need to outlive it. Instead, the compiler generates a hidden class (informally called a display class) with a field for each captured variable. The enclosing method's references to those variables are silently rewritten to go through an instance of this hidden class, and the lambda itself becomes an instance method on that same class. The delegate you end up holding wraps that instance as its target — which is precisely why the captured variable's storage lives on the heap, as long as anything keeps the delegate (or the hidden class instance) reachable, instead of dying with the stack frame.
This also explains the loop trap precisely: for a classic for loop, the compiler creates one display-class instance for the whole loop, because there's genuinely only one i variable across all iterations. For a foreach loop (since C# 5) and for a for loop where you declare a fresh local inside the loop body, the compiler creates a new display-class instance (or field value) each iteration, because a genuinely new variable exists each time. Multiple lambdas capturing the same display-class instance — as in the retry example above, where both lambdas captured attemptsUsed and orderId from the same method call — share that instance, which is why mutating a captured variable from one lambda is visible to another lambda that captured the same variable.
Many developers coming from languages with different capture semantics assume a lambda snapshots a variable's value the moment it's written. In C#, it does not — it captures the variable itself, by reference. If you genuinely want a frozen snapshot, you must deliberately copy the value into a new local variable before the lambda captures it (exactly the int local = i; fix above).
This is one of the most common pieces of outdated advice still floating around. Before C# 5 (2012), foreach genuinely did reuse one variable across all iterations, just like for. Since C# 5, each iteration gets a fresh variable. On any modern .NET project, you can trust foreach captures to behave intuitively — the trap is specific to a classic for loop's single mutable counter.
for loop's counter directly for (int i = 0; i < list.Count; i++) actions.Add(() => Use(i)); — every lambda ends up seeing the loop's final value of i.
Copy the counter into a fresh local declared inside the loop body first: int local = i; actions.Add(() => Use(local));. Or, when possible, iterate the collection itself with foreach instead of indexing with for.
A lambda captures an entire large object (or this) just to read one small field off it, then that lambda is stored somewhere long-lived (a static event, a cache). The whole object — not just the field — is kept alive as long as the lambda is reachable, because the closure holds a reference to the object, not a copy of the field.
Be deliberate about what a long-lived lambda captures. If only one field is truly needed, consider extracting it into a small local variable before the lambda is created, so the closure captures the smaller value rather than the whole containing object.
for loop shares one variable across every iteration; foreach (since C# 5) gives every iteration its own.
for loop has one mutable loop variable shared by every lambda created inside it — capture the counter directly and every lambda sees the loop's final value; copy it into a fresh local first to fix it.foreach loop, since C# 5, creates a fresh iteration variable every pass — closures over it behave intuitively with no workaround needed.Closures — especially the loop variable trap — are one of the most common sources of subtle bugs. Let's confirm the details are solid.
1. How does a C# lambda capture an outer variable?
Correct: B
Why B is correct: C# closures capture variables by reference — the lambda watches the actual variable's storage, so any change made to it after the lambda was created (from inside or outside the lambda) is visible the next time the lambda runs.
Why A is incorrect: This is the classic misconception. If capture were by value, the counter example (Action increment = () => count++;) would never accumulate across calls — but it does.
Why C is incorrect: Capturing outer variables is exactly what makes a lambda a closure — it's a core, well-supported capability, not a limitation.
Why D is incorrect: There's no serialization involved — capture is handled by the compiler generating a class with a field for the variable, accessed directly.
Reinforcement: Always assume a captured variable is a live reference, not a frozen copy, unless you deliberately copy it first.
2. What does this code print?
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.Write(i));
}
foreach (var a in actions) a();
Correct: B
Why B is correct: A classic for loop has exactly one i variable, mutated across all three iterations. All three lambdas capture that same variable. By the time any of them run (after the loop has finished), i equals 3.
Why A is incorrect: That would be the result if each lambda captured its own independent snapshot at creation time — which is what foreach gives you, but not a classic for loop capturing the counter directly.
Why C is incorrect: i is never reset to 0 after the loop; it ends at 3, the value that fails the loop condition.
Why D is incorrect: This code compiles fine — the behavior is a runtime surprise, not a compile-time error, which is exactly what makes it a dangerous trap.
Reinforcement: A classic for loop's counter is one shared variable — capturing it directly inside the loop body is the textbook closure trap.
3. How would you fix the previous question's code so it prints 012 instead?
Correct: B
Why B is correct: int local = i; written inside the loop body creates a brand-new variable on every single iteration. Each lambda then captures its own distinct local, frozen at that iteration's value, rather than the one shared i.
Why A is incorrect: Changing the delegate type doesn't change what variable is captured — the same sharing problem would occur regardless of Action vs Func<int>.
Why C is incorrect: The actions must run after being added to the list for this scenario to make sense; running them before they're created isn't a meaningful fix, and it wouldn't be possible here since the actions don't exist yet.
Why D is incorrect: for loops and lambdas work together fine — you just need to be deliberate about what gets captured, exactly as this fix demonstrates.
Reinforcement: When you need each lambda to see a different, frozen value from a for loop, copy the loop variable into a fresh local first.
4. If the loop in Question 2 were rewritten as foreach (int i in new[] { 0, 1, 2 }) instead of a classic for loop, what would it print, assuming modern C#?
Correct: B
Why B is correct: Since C# 5, a foreach loop creates a brand-new iteration variable on every pass. Each lambda captures a distinct variable holding that iteration's value, so the output matches the intuitive expectation.
Why A is incorrect: That was true for foreach before C# 5, but is no longer the case in any current version of C#.
Why C is incorrect: foreach variables can absolutely be captured by lambdas — that's a normal, well-supported pattern.
Why D is incorrect: Nothing resets the values to 0; each closure correctly retains the value from its own iteration.
Reinforcement: The loop-variable trap is specific to a classic for loop's single shared counter — foreach has been safe since C# 5.
You now understand exactly how closures capture state — and the one loop-variable trap worth remembering forever. Next: local functions, a named alternative to lambdas that changes how (and whether) capturing happens at all.
dotnetmadeeasy.com — Learn C# and .NET, the right way.