Learning outcome
By the end of this lesson, you should be able to predict what a lambda, delegate, or event callback will print when it is created inside a loop and invoked later. You should also know the safe patterns: create a per-iteration copy when you use for, understand the historical foreach behavior change in C# 5, and recognize why asynchronous work scheduled from a loop can magnify the bug.
Intuition
A closure does not capture a value snapshot; it captures a variable. If that variable keeps changing as the loop continues, every callback may read the final value instead of the value you expected at the moment you created the callback.
The important mental model is:
- the loop runs
- the callback is created
- the callback stores access to a variable
- the variable changes on later iterations
- the callback runs later and observes the current variable state
This is why loop-variable capture bugs often appear in delayed execution, event handlers, and task scheduling.
Deep dive
Why for loops are the classic trap
In a for loop, the iteration variable is typically declared once and then mutated each pass. If you capture that variable inside a lambda, each lambda points to the same storage location. When the loop ends, the variable usually contains its last value.
Why foreach changed in C# 5
Older C# versions reused the same iteration variable across foreach iterations, which made closure capture surprising there too. Starting with C# 5, each foreach iteration gets a fresh iteration variable, which means this pattern is now much safer for captured lambdas. That change reduced a common source of bugs, but it did not make all loops magically safe.
The safe pattern
If you need to capture a loop value in a for loop, copy it into a new local inside the loop body, then close over that local.
The same idea applies to delayed work:
Production-style guidance
When scheduling asynchronous work in a loop, assume invocation may happen after the loop has advanced or completed. The callback should therefore capture an immutable per-iteration value, not the mutable loop variable itself.
What to remember in interviews
Interviewers usually want three points:
- A closure captures a variable, not a value.
forloops commonly need a local copy for safety.foreachwas fixed in C# 5, so the modern behavior differs from historical code you may still encounter.
Failure modes
Common mistakes include:
- Capturing
idirectly in aforloop and expecting each callback to keep its own iteration number. - Assuming
Task.Runexecutes immediately enough to avoid capture bugs. It usually does not. - Forgetting that event handlers added in a loop may all point to the same loop variable.
- Porting old
foreachcode from pre-C# 5 behavior without realizing newer compilers changed the semantics. - Using mutable shared state in callbacks when a per-iteration local would be clearer and safer.
Interview drill
- Why do all callbacks sometimes print the same number after a
forloop finishes? - How did C# 5 change
foreachcapture semantics? - What is the safest one-line fix when building lambdas inside a
forloop? - Why is
Task.Runin a loop a good example of the bug? - What changes if the callback runs immediately rather than later?
Revision checklist
- I can explain that closures capture variables, not values.
- I know
forloops are the highest-risk loop form for capture bugs. - I know modern
foreachuses a fresh iteration variable per iteration. - I can apply the local-copy fix consistently.
- I can spot the issue in event handlers and
Task.Runscheduling code.
Production code
The example below demonstrates the bug and the fix in a single console app. It includes a loop that schedules work with Task.Run, because delayed execution is where capture mistakes become visible. The code prints deterministic success lines and uses checks so the semantic behavior is explicit.
Code walkthrough
The first method intentionally shows the bug. The for loop declares i once, each lambda returns i, and the callbacks are invoked after the loop. That means the lambdas observe the same shared variable and print the final value.
The second method fixes the issue by introducing copy inside the loop body. Each iteration gets a distinct local, so each lambda closes over a different variable instance.
The foreach method demonstrates modern C# behavior: each iteration gets its own iteration variable, so captured lambdas naturally preserve the element from that iteration. This is why foreach is often safer when you only need to read elements.
The Task.Run method shows why deferred execution matters. The tasks may start later, but even if they start quickly, the point is that the lambda should not depend on a mutable loop variable that may already have moved on.
The big rule is simple: if a callback runs after the loop can continue, close over a per-iteration local, not the loop variable itself.
Executable code examples
Capture bug and safe fixes
Program.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
var demo = new CaptureDemo();
demo.RunForLoopBug();
demo.RunForLoopFix();
demo.RunForeachSafe();
demo.RunTaskRunFix();
public sealed class CaptureDemo
{
public void RunForLoopBug()
{
var callbacks = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
callbacks.Add(() => i);
}
var results = callbacks.Select(callback => callback()).ToArray();
Console.WriteLine($"for-bug:{string.Join(',', results)}");
if (results.All(value => value == 3))
{
Console.WriteLine("for-bug:captured-final-value");
}
else
{
Console.WriteLine("for-bug:unexpected");
}
}
public void RunForLoopFix()
{
var callbacks = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
int copy = i;
callbacks.Add(() => copy);
}
var results = callbacks.Select(callback => callback()).ToArray();
Console.WriteLine($"for-fix:{string.Join(',', results)}");
if (results.SequenceEqual(new[] { 0, 1, 2 }))
{
Console.WriteLine("for-fix:per-iteration-copy");
}
else
{
Console.WriteLine("for-fix:unexpected");
}
}
public void RunForeachSafe()
{
var callbacks = new List<Func<int>>();
int[] values = [10, 20, 30];
foreach (int value in values)
{
callbacks.Add(() => value);
}
var results = callbacks.Select(callback => callback()).ToArray();
Console.WriteLine($"foreach:{string.Join(',', results)}");
if (results.SequenceEqual(values))
{
Console.WriteLine("foreach:fresh-iteration-variable");
}
else
{
Console.WriteLine("foreach:unexpected");
}
}
public void RunTaskRunFix()
{
var tasks = new List<Task<int>>();
for (int i = 0; i < 3; i++)
{
int copy = i;
tasks.Add(Task.Run(() => copy));
}
Task.WaitAll(tasks.ToArray());
int[] results = tasks.Select(task => task.Result).ToArray();
Console.WriteLine($"taskrun:{string.Join(',', results)}");
if (results.SequenceEqual(new[] { 0, 1, 2 }))
{
Console.WriteLine("taskrun:per-iteration-copy");
}
else
{
Console.WriteLine("taskrun:unexpected");
}
}
}