Back to Blog
C#

C# Lambda Variable Capture: Closures and Pitfalls

c# lambda variable capture: Learn how C# lambda expressions capture variables, why loop variables cause bugs, and how closures affect memory and performance.

lambda expressionsclosuresvariable captureC# loopsdelegatesmemory management
Diagram illustrating how a C# lambda captures variables from its enclosing scope, forming a closure.

When you create a lambda expression in C#, it can reference variables from the enclosing method. This behavior, called c# lambda variable capture, is convenient but can lead to surprising results when the captured variable changes later. Understanding how the compiler implements capture is essential for writing correct code in loops, async methods, and event handlers.

How Lambda Expressions Capture Variables

A lambda expression is more than a function pointer. When it references a local variable or parameter from the surrounding method, the compiler creates a closure. The closure is a hidden class that holds the captured variables as fields, and the lambda becomes a method on that class. The original method and the lambda then share the same instance of that class, so changes made in one place are visible in the other.

int counter = 0; Func<int> increment = () => ++counter; Console.WriteLine(increment()); // 1 Console.WriteLine(counter); // 1

Here, counter is not copied into the lambda. The lambda reads and writes the same counter variable that the outer method sees. This is capture by reference, not by value. The compiler rewrites the code to use a display class that holds counter, and both the outer method and the lambda operate on that shared instance.

The Closure: Captured Variables Live Beyond the Method

A closure keeps captured variables alive even after the method that created the lambda has returned. This is useful when you need a delegate that remembers state, but it also means the garbage collector cannot reclaim those variables until the delegate itself is no longer referenced.

Func<int> CreateCounter() { int count = 0; return () => ++count; } var counter = CreateCounter(); Console.WriteLine(counter()); // 1 Console.WriteLine(counter()); // 2

The local variable count would normally be destroyed when CreateCounter returns, but because the lambda captures it, the variable lives on inside the closure. Each call to CreateCounter creates a new closure instance, so counters are independent. This is the foundation of many functional programming patterns, but it has a cost: each closure allocates an object on the heap.

The Classic Loop Variable Problem

Before C# 5, the foreach loop variable was captured by reference in a way that caused a well-known bug. If you created lambdas inside a foreach loop and invoked them later, every lambda saw the final value of the loop variable, not the value at the time the lambda was created.

var actions = new List<Action>(); foreach (int i in new[] { 1, 2, 3 }) { actions.Add(() => Console.WriteLine(i)); } foreach (var action in actions) { action(); // Prints 3, 3, 3 in C# 4 and earlier }

The reason is that the compiler used a single display class instance for the entire loop, and i was a single variable that changed on each iteration. The lambda captured that one variable, not a copy. In C# 5 and later, the compiler changed the semantics for foreach: each iteration gets its own copy of the loop variable, so the output becomes 1, 2, 3. This change was a breaking change, but it fixed the most common capture pitfall.

foreach vs for: Differences in Capture Behavior

The for loop does not have the same protection. The loop variable in a for loop is still captured by reference, and the compiler does not create a new variable per iteration. This means the classic bug still occurs with for loops in all C# versions.

var actions = new List<Action>(); for (int i = 0; i < 3; i++) { actions.Add(() => Console.WriteLine(i)); } foreach (var action in actions) { action(); // Prints 3, 3, 3 }

To get the expected output, you must introduce a new local variable inside the loop body and capture that instead.

for (int i = 0; i < 3; i++) { int copy = i; actions.Add(() => Console.WriteLine(copy)); }

The copy variable is re-created on each iteration, so each lambda captures a distinct instance. This is the standard workaround for for loops and for any other situation where you need a snapshot of a changing variable.

Memory and Performance Implications of Captures

Every lambda that captures variables allocates a closure object. If you create many lambdas in a hot path, this can increase memory pressure and garbage collection frequency. The compiler also generates an extra method for the lambda body, which adds a small amount of metadata. For most applications, this overhead is negligible, but it becomes noticeable when you create thousands of closures per second, such as inside a tight loop that also allocates other objects.

Another subtlety is that captured variables are hoisted to the heap even if they are value types. A simple int that would otherwise live on the stack becomes a field in a heap-allocated display class. This changes the lifetime and can affect cache locality. In performance-critical code, you may want to avoid capture altogether by passing values as parameters to a static lambda or by using a local function that does not capture state.

Capturing by Reference vs Value: What C# Does

C# always captures variables by reference, not by value. There is no syntax to capture a variable by value directly. If you need a snapshot, you must create a new local variable and capture that. This is true for all lambda expressions and local functions. The distinction matters when you combine capture with asynchronous code, because the captured variable may change before the async continuation runs.

int value = 10; Task.Run(() => Console.WriteLine(value)); value = 20; // The lambda may print 20, not 10, depending on timing.

If you need the original value, copy it first:

int value = 10; int captured = value; Task.Run(() => Console.WriteLine(captured)); value = 20; // The lambda prints 10.

This pattern is also important in event handlers that are attached and detached multiple times, because the closure may hold references to objects that should be collected.

Avoiding Capture Pitfalls in Async and Event Handlers

In async methods, captured variables are shared across awaits. If you start multiple async operations in a loop, each operation may see the final value of the loop variable unless you copy it. The same applies to event handlers that are added inside a loop; each handler captures the same variable unless you create a local copy.

for (int i = 0; i < 3; i++) { int index = i; button.Click += (s, e) => Console.WriteLine(index); }

Without the index copy, all three handlers would print the final value of i (which would be 3 after the loop). Copying the loop variable ensures each handler remembers its own index. This is a common source of bugs in UI code and in parallel processing.

Practical Guidance for Lambda Captures

When you write a lambda that references a local variable, ask yourself whether you need the current value or a snapshot. If the variable changes after the lambda is created, you almost certainly want a snapshot. Use a local copy for that purpose. If you are using foreach, the compiler already gives you a fresh variable per iteration, so you can rely on that behavior in C# 5 and later. For for loops and for custom iteration logic, you must create the copy manually.

Also consider the lifetime of the closure. If a lambda is stored in a static field or an event that lives for a long time, it will keep all captured variables alive for that entire period. This can lead to memory leaks if the captured variables reference large objects. To avoid this, avoid capturing large objects unnecessarily, and unsubscribe event handlers when they are no longer needed.

Finally, remember that local functions in C# have similar capture semantics but are implemented differently. A local function that does not capture any variables can be static, which avoids the closure allocation. If you are writing a helper that only uses its parameters, mark it static to eliminate the closure and improve performance. This is a simple optimization that the compiler can apply automatically in some cases, but being explicit makes the intent clear.

c# lambda variable capture: Practical Usage and Code Example | RYUSLOG DEV