Back to Blog
C#

C# Lambda Syntax: Expressions, Statements, and Captures

c# lambda syntax: Learn the practical C# lambda syntax: expression and statement lambdas, parameter inference, variable capture, and common pitfalls.

lambda expressionsdelegatesFuncActionLINQclosures
Illustration of C# lambda arrow syntax transforming input to output, with a closure symbol.

The C# lambda syntax is built around a simple arrow: =>. A lambda expression takes the form (parameters) => expression or (parameters) => { statements; }. This compact syntax lets you define anonymous functions inline, which is essential for LINQ queries, event handlers, and passing behavior as arguments. Understanding the exact syntax rules, how type inference works, and how variable capture behaves is what separates clean, correct code from subtle runtime bugs.

Expression Lambdas vs. Statement Lambdas

An expression lambda has a single expression on the right side of the => operator. The expression's result becomes the return value. For example:

Func<int, int> square = x => x * x;

This lambda takes one integer parameter x and returns x * x. The compiler infers the parameter type from the delegate type Func<int, int>. Expression lambdas are concise and work well for simple transformations.

A statement lambda uses braces and can contain multiple statements. It requires an explicit return statement if it produces a value:

Func<int, int> abs = x => { if (x < 0) return -x; return x; };

Statement lambdas are necessary when the logic requires more than a single expression, such as loops, conditionals, or variable declarations. They cannot be used in expression trees, which is a key limitation. If you need to build an expression tree for LINQ to SQL or similar providers, you must use an expression lambda.

Parameter Syntax and Type Inference

The parameter list on the left side of => follows rules similar to regular method parameters, but with some shortcuts. When the delegate type provides parameter types, you can omit them entirely:

Func<int, int, int> add = (a, b) => a + b;

If you need explicit types, you must include them in parentheses:

Func<int, int> cube = (int x) => x * x * x;

A single parameter can omit parentheses entirely:

Func<int, bool> isPositive = x => x > 0;

Zero parameters require empty parentheses:

Action sayHello = () => Console.WriteLine("Hello");

Type inference works from the target delegate type. If the compiler cannot determine a parameter type because the target is not a delegate or is var, you must specify types explicitly. This often happens when you assign a lambda to var—the compiler cannot infer the delegate type, so you must use a concrete delegate or a method that expects a delegate.

Capturing Variables and Closures

A lambda can reference variables from the enclosing scope. This is called variable capture, and the lambda becomes a closure. The captured variable is not copied; the lambda holds a reference to the variable itself. This has important consequences for lifetime and mutation.

int factor = 3; Func<int, int> multiply = x => x * factor; factor = 5; Console.WriteLine(multiply(2)); // Output: 10

The lambda sees the current value of factor at invocation time, not at creation time. This behavior is useful for configuration, but it can lead to surprising results in loops. Consider the classic foreach capture issue:

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 older C# versions }

In C# 5 and later, the foreach loop variable is scoped per iteration, so this specific issue is fixed for foreach. However, the for loop variable i is shared across iterations, so the output is 3, 3, 3. To capture the current value, copy it to a local variable inside the loop:

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

Now each lambda captures a distinct local variable, and the output is 0, 1, 2. Understanding this distinction prevents subtle bugs in asynchronous code and event handlers.

Using Lambdas with Func and Action

The Func and Action delegate types are the most common targets for lambdas. Func takes up to 16 input parameters and returns a value; the last type parameter is the return type. Action takes parameters but returns void. For example:

Func<string, int> parseLength = s => s.Length; Action<string> print = s => Console.WriteLine(s);

Lambdas are frequently passed to LINQ methods like Where, Select, OrderBy, and Aggregate. Because these methods expect delegate parameters, the lambda syntax integrates seamlessly:

var evenNumbers = numbers.Where(n => n % 2 == 0); var squares = numbers.Select(n => n * n);

The compiler converts the lambda into either a delegate instance or an expression tree, depending on the target type. If the parameter is Expression<TDelegate>, the lambda is compiled into an expression tree, which is how LINQ providers translate C# code into SQL or other query languages. This distinction is critical: expression lambdas cannot contain statements or body blocks, and they cannot reference certain constructs like async or await in older C# versions.

Common Mistakes and Pitfalls

One frequent mistake is assuming that a lambda captures a snapshot of the variable. As shown earlier, it captures the variable itself. Another mistake is using a lambda where a method group would be clearer. For example, list.ForEach(Console.WriteLine) is more readable than list.ForEach(x => Console.WriteLine(x)). Method groups are also slightly more efficient because they avoid an extra indirection, though the difference is negligible in most cases.

Another pitfall is overusing lambdas in places where a named local function improves readability and stack traces. Local functions (introduced in C# 7) can be recursive, generic, and can be declared after use. They also avoid delegate allocation when they are not converted to delegates. If you find yourself writing a lambda that contains many statements or is called recursively, a local function is often a better choice.

Lambdas that capture variables cause the compiler to generate a closure class. Each closure instance holds the captured variables. If you create many delegates that capture different values, you may allocate many closure objects. This is generally acceptable for typical application code, but in high-performance paths—such as tight loops that create thousands of delegates per second—it can add pressure on the garbage collector.

Performance and Allocation Considerations

Every lambda expression that is converted to a delegate allocates a delegate instance. If the lambda captures no variables, the compiler can cache a single static delegate instance. If it captures variables, a new closure object is allocated each time the lambda is created. For example:

// No capture: compiler caches a single delegate Func<int, int> square = x => x * x; // Capture: a new closure is allocated each time int offset = 10; Func<int, int> addOffset = x => x + offset;

If you call a method that takes a delegate and you pass a capturing lambda, the closure is allocated on every call. This is usually fine, but in hot paths you can avoid it by using a static lambda or a local function that does not capture. For instance, instead of:

for (int i = 0; i < 1000; i++) { Process(x => x + i); // allocates a new closure each iteration }

You can move the loop inside the lambda or use a local function that takes i as a parameter:

for (int i = 0; i < 1000; i++) { int captured = i; Process(x => x + captured); // still allocates, but the closure is per iteration }

There is no way to avoid the closure if you need to capture a value that changes per iteration. The best you can do is minimize the number of delegates you create. In scenarios where the same delegate is reused, store it in a field or static readonly field. The .NET runtime optimizes some cases, but relying on that is not a substitute for understanding the allocation pattern.

Compatibility and Maintainability

Lambda syntax has been part of C# since version 3.0, so it works in virtually all modern codebases. However, some features have been added over time. For example, C# 9 introduced static anonymous functions, which prevent capturing variables and can improve performance by avoiding closure allocation. A static lambda is declared with the static keyword:

Func<int, int> square = static x => x * x;

This lambda cannot capture variables from the enclosing scope, and the compiler enforces that. If you try to capture, you get a compile-time error. This is useful in hot paths where you want to guarantee no closure allocation. It also makes the intent explicit: the lambda is self-contained.

For maintainability, prefer expression lambdas for simple transformations and reserve statement lambdas for logic that genuinely needs multiple statements. If a lambda becomes too complex, extract it into a named method or a local function. Named methods also produce better stack traces in exceptions, which simplifies debugging.

Another compatibility consideration is that expression trees cannot contain statement lambdas or async lambdas. If you are using an ORM like Entity Framework, you must use expression lambdas so the query provider can translate them. If you need to call an async method inside a LINQ query, you cannot use an expression tree; you must materialize the query first and then use async lambdas with Task-based methods.

Finally, be aware of how lambdas interact with overload resolution. If a method has overloads that accept Func<T> and Expression<Func<T>>, the lambda syntax is ambiguous. The compiler chooses the expression tree overload when the lambda is an expression lambda and the parameter type is Expression<TDelegate>. If you need to force one, you can cast the lambda to the specific delegate type. This is a rare but real edge case that can cause confusing compile errors.

c# lambda syntax: Practical Usage and Code Examples | RYUSLOG DEV