Back to Blog
C#

C# Lambda Expression: Syntax, Variable Capture, and Performance

Understand C# lambda expression syntax, variable capture, delegate usage, and performance tradeoffs with practical code examples.

C#LambdaLINQDelegatesClosuresPerformance
A visual metaphor of a C# lambda expression showing an arrow from input to output with a delegate and closure concept.

A C# lambda expression is an anonymous function that you can use to create delegates or expression trees. The most common form is (parameters) => expression, where the compiler infers the parameter types and return type from the context. For example, x => x * 2 doubles its input. Lambdas appear everywhere in modern C#: LINQ queries, event handlers, and asynchronous code. Their concise syntax makes inline logic readable, but the same brevity can hide subtle behavior around variable capture and allocation.

Lambda Expression Syntax and Basic Forms

There are two syntactic forms: expression lambdas and statement lambdas. An expression lambda has a single expression on the right side of =>, and the compiler treats that expression as the return value. A statement lambda uses braces and can contain multiple statements, but it must explicitly use return when producing a value.

Func<int, int> square = x => x * x; // expression lambda Func<int, int> squareWithLog = x => { Console.WriteLine($"Squaring {x}"); return x * x; }; // statement lambda

Parameter lists can be explicit or implicit. When the compiler can infer types from the delegate signature, you can omit them. If you need to specify types, you must use parentheses: (int x) => x * 2. For a single parameter, parentheses are optional. For zero parameters, use () => .... For multiple parameters, parentheses are required: (a, b) => a + b.

Expression lambdas can also be converted to expression trees when the target type is Expression<TDelegate>. This is how LINQ to SQL or Entity Framework translates C# logic into SQL. The same lambda syntax works, but the compiler generates code that describes the operation rather than a delegate.

Using Lambdas with Func and Action Delegates

Lambdas are most often assigned to Func or Action delegate types. Func represents a method that returns a value and can take up to sixteen input parameters. Action represents a method that returns void. The last generic argument of Func is always the return type, so Func<int, string> takes an int and returns a string.

Func<int, int, int> add = (a, b) => a + b; Action<string> log = message => Console.WriteLine(message);

These delegates are the backbone of higher-order functions. You can pass a lambda to a method that expects a delegate, such as List<T>.FindAll or Array.ConvertAll. The lambda is compiled into a method (possibly a static method if it captures nothing) and wrapped in a delegate instance.

A common mistake is to assume that a lambda is a Func or Action by default. It is not. The compiler uses the target type to resolve the parameter and return types. With an implicitly typed lambda such as x => x * 2, var still fails because there is no target type and the parameter type cannot be inferred. This is why var lambda = x => x * 2; does not compile. In C# 10 and later, a lambda with explicit parameter types has a natural type, so var converter = (string s) => int.Parse(s); is valid.

Capturing Variables and Closure Behavior

A lambda can reference variables from the enclosing scope. This creates a closure, meaning the lambda captures the variable by reference, not by value. The captured variable lives as long as the delegate does, even if the original method has returned. This is powerful but can lead to surprising behavior, especially in loops.

int factor = 3; Func<int, int> multiplier = x => x * factor; factor = 5; Console.WriteLine(multiplier(2)); // Outputs 10, not 6

The lambda sees the current value of factor at invocation time, not at creation time. This is by design. The same rule applies to foreach loop variables in older C# versions. In C# 5 and later, the loop variable in a foreach is captured per iteration, so each closure gets its own copy. For for loops, the loop variable is shared unless you create a local copy inside the loop.

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 because the for-loop variable is shared; with a local copy it prints 0, 1, 2 }

To capture the current value, copy it to a local variable inside the loop:

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

Closures also affect memory. Each captured variable is hoisted into a compiler-generated class. If you create many delegates that capture distinct variables, you allocate additional objects. In performance-sensitive paths, consider whether a static lambda or a local function without capture is a better fit.

Lambdas in LINQ and Collection Operations

LINQ is where lambdas shine. Methods like Where, Select, OrderBy, and Aggregate accept delegates that define the operation. The lambda syntax keeps the query readable and keeps the logic close to the data.

var numbers = new[] { 1, 2, 3, 4, 5 }; var evenSquares = numbers .Where(n => n % 2 == 0) .Select(n => n * n);

These queries use deferred execution. The lambda is not executed until you enumerate the result. You can build the query pipeline without enumerating the source; enumeration happens when you iterate the result. However, be careful when capturing variables that change between query creation and enumeration. The lambda will see the current values at enumeration time, which can produce unexpected results if the captured variable is modified.

For example, if you build a query inside a loop and capture the loop variable, all iterations may reference the same variable. Use a local copy to avoid this, as shown earlier.

LINQ also supports expression trees when the source is IQueryable, such as with Entity Framework. The lambda is converted to an expression tree, and the provider translates it to SQL. This works only for expression lambdas, not statement lambdas. A statement lambda cannot be converted to an expression tree, so the compiler may fall back to the Enumerable overload and execute the operation client-side instead of translating it.

Performance and Allocation Considerations

Lambdas can introduce allocations. When a lambda captures no variables, the compiler can cache a single static delegate. When it captures variables, a new closure object is created each time the lambda is instantiated. This is often negligible, but in hot paths it can add pressure on the garbage collector.

Consider a method that processes thousands of items and uses a lambda that captures a local variable:

public void Process(List<int> items, int offset) { var result = items.Select(x => x + offset); // allocates a closure per call }

If Process is called frequently, each call creates a closure object. To avoid the closure, avoid capturing offset in a lambda at the call site. Move the whole transformation into a static helper that receives both the items and the offset:

public void Process(List<int> items, int offset) { var result = AddOffset(items, offset); } private static IEnumerable<int> AddOffset(IEnumerable<int> items, int offset) { foreach (var item in items) { yield return item + offset; } }

This removes the capturing lambda from the call site. If you keep items.Select(x => x + offset) or write items.Select(x => AddOffset(x, offset)), the lambda still captures offset, so a closure is allocated per call. Note that the iterator helper itself has its own allocation profile, but it does not create a closure just to carry offset into a lambda.

Another consideration is the size of the generated code. Each lambda compiles to a separate method. A large number of lambdas can increase the assembly size and JIT compilation time. In most applications this is not a problem, but in code that generates many dynamic lambdas (e.g., through expression trees), it can matter.

Maintainability and Readability Tradeoffs

Lambdas are concise, but they can become unreadable when the logic is complex. A multi-line statement lambda with several captured variables is harder to test and debug than a named local function. C# 7 introduced local functions, which are similar to lambdas but can be recursive and have a name, making stack traces clearer.

int Factorial(int n) => n <= 1 ? 1 : n * Factorial(n - 1);

Local functions are named methods in the local scope. They do not allocate a delegate unless you explicitly convert them to one. They also support ref and out parameters, which lambdas do not.

When deciding between a lambda and a local function, consider whether the logic will be reused, whether it needs to be passed as a delegate, and whether the closure capture is intentional. A lambda is appropriate for short, single-use logic that fits on one line. A local function is better for longer logic that benefits from a name and can be called without delegate allocation.

Method groups can also be used where a lambda is expected. For example, items.Select(ConvertToString) is equivalent to items.Select(x => ConvertToString(x)). This can be more readable, but method groups can cause overload resolution issues when the method has multiple overloads, so lambdas are sometimes clearer.

Common Mistakes and Debugging

One common mistake is letting a lambda capture an IDisposable and keep it alive longer than intended. The captured object stays alive as long as the delegate is reachable, which can delay disposal. Ensure that captured objects are disposed explicitly when the delegate is no longer needed.

Another issue is async lambdas. An async lambda returns a Task when the target delegate expects one. For example, Func<Task> can be assigned an async lambda. However, if you use an async void lambda (e.g., for an event handler), exceptions cannot be caught by the caller and may crash the process. Prefer async Task lambdas for anything that is not an event handler.

Debugging lambdas can be tricky because the compiler generates names like <Main>b__0_0. In Visual Studio, you can set breakpoints inside the lambda body, and the debugger will show the captured variables in the Locals window. If you need a more readable stack trace, consider using a local function instead.

Finally, remember that a lambda expression becomes useful only when the compiler converts it to a delegate or an expression tree. Passing a lambda to a method that expects a specific delegate type works because the compiler performs that conversion. If the target type is ambiguous—for example, using var with an implicitly typed lambda, or using a lambda in a conditional expression—the compiler will reject it. Provide an explicit target type when the compiler cannot infer one.

Where Lambdas Fit in Modern C#

Lambdas remain the primary way to pass behavior as data in C#. They are essential for LINQ, event handling, and functional-style programming, and they work alongside newer C# features such as pattern matching and records.

When you use a C# lambda expression, keep the closure behavior and allocation cost in mind. Prefer expression lambdas for simple transformations, use statement lambdas sparingly, and consider local functions when the logic grows. With these practices, lambdas stay readable and efficient across your codebase.

C# Lambda Expression: Syntax, Variable Capture, and Performance Examples | RYUSLOG DEV