C# Lambda Expression: Syntax, Capture, and Performance
c# lambda expression: Understand C# lambda expression syntax, closure capture, delegate usage, and performance implications with practical code examples.
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 lambda expression itself has no type until assigned or passed to a parameter. The compiler uses the target type to resolve the parameter and return types. This is why var lambda = x => x * 2; fails to compile—there is no target type.
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 in C# < 5; 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. This means you can build a query pipeline without iterating the source multiple times. 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. If you use a statement lambda with IQueryable, the compiler will force it into a delegate and execute it client-side, which can break the query translation.
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 this, you can pass the captured value as a parameter to a static lambda or use a local function that takes the value as an argument.
public void Process(List<int> items, int offset) { var result = items.Select(x => AddOffset(x, offset)); } private static int AddOffset(int x, int offset) => x + offset;
This still allocates a delegate, but not a closure. The delegate may be cached if the lambda is static and does not capture. In .NET Core 3.0 and later, the runtime can cache static lambdas, so repeated calls reuse the same delegate instance.
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 compiled as methods on the containing type, and 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)). Method groups are often more readable and can be cached by the compiler if they are static. However, 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 using a lambda in a using statement or with IDisposable resources. A lambda that captures a disposable object keeps it alive until the delegate is released. This 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 lambda expressions are not delegates until they are converted. Passing a lambda to a method that expects a specific delegate type works because the compiler performs the conversion. But if you try to use a lambda in a context where the target type is ambiguous, such as with var or a conditional expression, the compiler will reject it. Always provide an explicit target type when the compiler cannot infer it.
Where Lambdas Fit in Modern C#
Modern C# has evolved beyond simple lambdas. The switch expression, pattern matching, and records all work well with lambdas, but they do not replace them. Lambdas remain the standard way to pass behavior as data. They are essential for LINQ, event handling, and functional-style programming in C#.
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.