Last Updated: May 22, 2026
A lambda expression is a small piece of code you can pass around as a value, the same way you'd pass an int or a string. The => syntax turns a method body into something a Func, Action, or any custom delegate can hold, and the compiler handles the wiring so you don't have to declare a named method just to hand off two lines of logic. This lesson covers the syntax in full, the rules the compiler uses to give a lambda a type, the closure machinery that fires when a lambda captures local variables, and the classic capture-in-a-loop trap that catches almost every C# developer at least once.
A method is the unit of behavior in C#. When you want to hand a piece of behavior to someone else (a sort routine, a filter, an event handler), you need a way to refer to that behavior as a value. Delegates do that job: a delegate variable holds a reference to a callable target, and the call site invokes whatever the variable currently points at. A custom delegate declares the shape of the callable, Func/Action/Predicate are the built-in shapes you'll use most often, and a delegate variable can hold a single target or chain several together.
The friction in all of that is naming. To pass a one-line check into a sort comparator or a filter, you had to declare a named method somewhere, even if you only ever called it from one place. The named method had to live on a class, with a signature that matched the delegate, often with a name that nobody else cared about. Code that should have read like a single thought (sort these products by price) ended up split across two files.
Lambdas remove the ceremony. You write the body inline at the call site, and the compiler synthesizes the method, the delegate target, and the wiring internally. The result is code that puts the behavior where the reader expects to find it, next to the call that uses it.
The (a, b) => a.Length.CompareTo(b.Length) argument is a lambda expression. List<T>.Sort accepts a Comparison<T> delegate, the compiler sees the lambda and produces a delegate of that exact type, and the sort uses it to order the items by name length. The comparator lives where the sort happens, not in a separate CompareByLength method on some helper class.
That's the value lambdas add to everyday C#: behavior as a first-class value, with the syntax to match. Everything else in this lesson is mechanics for using that idea well.
=> SyntaxThe lambda operator => separates the parameter list on the left from the body on the right. The simplest form is one parameter and one expression, with no type annotations and no braces:
The lambda price => price * 1.08m reads as "given a price, return price * 1.08m". The compiler matches that shape against the target type Func<decimal, decimal>, which expects one decimal parameter and a decimal return, and the lambda becomes the body of an auto-generated method that the delegate variable points at.
When the lambda has exactly one parameter and no type annotation, you can write it without parentheses. As soon as you add a type, drop a parameter, or add a second parameter, parentheses are required.
The single-parameter, no-parens form is the most common shape in practice because most lambdas take one input and transform it. The parenthesized forms are not stylistically worse; some teams use them universally for consistency. Pick one style and stay with it.
The body on the right of => is an expression in all four cases above. An expression has a value, which the compiler returns as the result of the lambda. There's no return keyword and no semicolon inside the body, because nothing inside the body is a statement. This shape is called an expression-bodied lambda, and it's the dominant form.
When the logic doesn't fit in a single expression (a condition, a loop, a local variable, a try/catch), you switch to a statement-bodied lambda. The body becomes a block in braces, and the rules inside the block are the same as any method body, including an explicit return for non-void results.
The braces flip the body into statement mode. Each line ends with a semicolon, the return keyword is required for any path that yields a value, and the closing brace is followed by a semicolon that terminates the variable declaration itself. The lambda is still a single value being assigned to applyDiscount, the body is just longer.
Statement bodies are convenient when the logic genuinely needs them, but a multi-line lambda starts to look like a hidden method. The rule of thumb most C# teams settle on: if the body grows past three or four lines, lift it into a named method. The named method gets a name (which is documentation), an XML doc comment, easier testing, and a stack trace that's easier to read. Lambdas earn their keep when the body is short enough that you're not paying a readability tax to keep it inline.
An Action-shaped lambda (no return value) can also be statement-bodied. The body just doesn't return anything.
Both Func-shaped and Action-shaped lambdas accept expression or statement bodies. The shape of the target delegate decides whether a return value is required; the body shape decides whether you need return and semicolons.
A lambda on its own has no type. The expression x => x * 2 is just syntax; it isn't a Func<int, int> or a Func<double, double> or anything else until something tells the compiler what it should be. That something is the target type: the type of the variable, parameter, return slot, or cast that the lambda is being assigned into. The compiler reads the target type, sees what delegate shape it requires, and binds the lambda to that shape.
The same lambda body x => x * 2 becomes three different delegates. The variable on the left says what x is, and the compiler types the lambda accordingly. Without that left-hand side, the lambda would be ambiguous: x could be int, double, decimal, anything that supports *.
Three places provide the target type most often:
Func<int, int> f = x => x * 2; reads the type from Func<int, int>.products.Sort((a, b) => a.Length - b.Length) reads the type from Comparison<string>, the parameter type of Sort.Func<int, int> can write return x => x * 2; and the return type provides the target.Custom delegate types (the ones declared in lesson 01 of this section) work exactly the same way as Func and Action. If you've declared delegate decimal PriceTransform(decimal price);, you can assign a lambda to a variable of that type and the compiler will use the delegate's signature as the target.
The compiler doesn't care whether the delegate type came from the BCL or from your own code. Both PriceTransform and Func<decimal, decimal> describe the same shape (one decimal in, one decimal out), and the lambda binds to either. You can't, however, assign the same lambda variable to both types in succession, because the variable's static type is fixed at declaration. If you need to share one lambda between two delegate types, you assign the lambda to each variable separately.
Starting in C# 10, you can use var with a lambda, and the compiler picks a natural delegate type for you (a Func or Action matching the lambda's shape). The feature is convenient for quick scripts but slightly less precise, because var hides the exact type your code is producing.
Note the explicit (decimal price) parameter type. With var, the compiler has no left-hand-side delegate to read from, so the lambda must tell it what price is. The next section is about exactly that.
A lambda that captures no outer variables can be cached by the compiler into a static field, so creating it is essentially free after the first use. A lambda that captures outer variables allocates a small object on the heap each time the enclosing method is called. For most code this cost is invisible; in a hot loop that creates thousands of capturing lambdas per second, it's .
The default style for lambdas is to let the target type drive the parameter types: write x not (int x). The shorter form reads better when the target is obvious, and most production C# uses it.
You need explicit types in a handful of situations. The most common is when no target type exists yet, like the var example in the previous section. The second is when the compiler has multiple candidate target types and can't decide which one you meant. Overload resolution is the usual cause:
The commented-out call is ambiguous because x => x matches both Func<int, int> (an int going in and out) and Func<string, string> (a string going in and out). The compiler refuses to guess, and you have to spell out the parameter type to break the tie. Once (int x) appears, only the first overload fits and the second is ruled out.
The third situation is when you want to be deliberately explicit for readers. A lambda nested inside a longer expression can be hard to read if the parameter types aren't obvious, and a tiny annotation can save the next person several seconds. This is a judgment call; don't add types just to add types, but don't strip them when their absence costs clarity.
You can mix explicit and implicit parameters in some C# versions, but the safest rule is all-or-nothing: either annotate every parameter or annotate none. That's also what tooling tends to format toward.
A lambda doesn't run in isolation. It can reach out into the enclosing scope and use variables that were declared before it, and it can use them later, after the enclosing method has returned. This ability is called variable capture, and the lambda together with the captured environment is called a closure.
The lambda reads discountPercent from the enclosing scope. The first call uses 10m, the second uses 25m, and the change is visible because the lambda is referencing the variable itself, not a snapshot of its value. That's the key thing to remember: a closure captures the variable, not its current value.
What does "capture the variable" mean in practice? A local variable in a method normally lives in the stack frame of that method, and the stack frame disappears when the method returns. But a lambda you create inside the method can be stored somewhere (a field, a list, an event handler) and called long after the method has finished. The local has to survive that, which means it can't live on the stack anymore.
The compiler solves this by lifting the captured local into a heap object. It generates a hidden class with a field for each captured variable, allocates one instance of that class when the method starts, rewrites every read and write of the local to go through that field, and gives the lambda a reference to the same object. The local "variable" you see in source code is, after compilation, a field on a synthesized class on the heap.
The "local" discountPercent you see in the source isn't really a stack slot. The compiler rewrote it to live on the closure object, and both the surrounding method and the lambda's body read and write through the same field. When the surrounding method returns, the closure object stays alive as long as something (in this case, the applyDiscount delegate) keeps referencing it.
This rewrite has consequences for performance and correctness, and the rest of this section walks through them.
The first consequence is that the lambda sees the variable, not its value. If anything (the surrounding method, another lambda) changes the variable, the lambda sees the change next time it reads.
The second consequence is that multiple lambdas can share captured state. If two lambdas capture the same local, they share the same field on the same closure object, and one lambda's writes are visible to the other.
Both lambdas captured callCount, both ended up with a reference to the same closure object, and their reads and writes hit the same field. This is occasionally exactly what you want (a small counter, a cached result) and occasionally a surprise. Either way, it's the natural consequence of capturing by reference.
The third consequence is allocation cost. Every time the enclosing method runs, it allocates a fresh closure object on the heap and a fresh delegate that points at it. For most code this is fine. In a tight loop that creates lambdas per iteration, it can become the program's dominant allocation.
Each loop iteration introduces a fresh factor, a fresh closure object, and a fresh delegate. Three lambdas, three closures, three allocations. That's the right behavior here, because each lambda needs its own factor. The loop is doing this allocation work; in a different scenario where you want all the lambdas to share state, hoist the captured variable outside the loop.
A capturing lambda allocates a closure object plus a delegate object each time the enclosing method runs, and the closure stays alive as long as anything holds the delegate. A non-capturing lambda is cached into a static field and effectively free. If you see a lambda inside a hot loop and the lambda doesn't actually need to capture, refactor it to take its inputs as parameters instead, and the compiler will hoist it to a single static instance.
A lambda that captures nothing is in a class of its own. The compiler can prove it doesn't need any per-call state, so it generates a single static instance and reuses it for every call.
This is the cheapest form of lambda. No closure, no per-call allocation, just a static method behind a delegate. The same lambda body written in two different places does get two cached instances (one per call site), but each is allocated exactly once for the lifetime of the program.
The "captures the variable, not the value" rule has a famous failure mode: capturing the loop variable of a classic for loop. The pattern is something like "build a list of lambdas, each customized with the loop index," and the result is that every lambda ends up pointing at the same variable, which by the time the lambdas run has reached the final value.
Every lambda prints 3. The reason is exactly the closure mechanics from the previous section: there's one i for the entire loop (it lives on the closure object), every iteration captures the same i, and after the loop ends i holds 3. By the time the actions run, they all read the same field, which has reached its final value of 3.
The fix is to introduce a fresh variable inside the loop body, before the lambda is created. Each iteration then captures its own variable, with its own field on its own closure object.
The line int captured = i; is the entire fix. Each iteration creates a new captured, the lambda captures that new variable, and each lambda gets its own closure object with its own field. The output now reflects the iteration that built each lambda.
The foreach loop is different, and the difference is the source of a lot of confusion. Up through C# 4, foreach had the same trap as for: there was one loop variable for the entire loop, and capturing it produced the same "all lambdas point to the last value" result. C# 5 changed this. Since C# 5, foreach introduces a fresh loop variable on every iteration, so capturing it does what learners expect.
This works correctly on any modern .NET because of the C# 5 fix. If you've ever read a blog post from before 2012 that warned about capturing the foreach variable, the warning still applied to for (which behaves the way the original foreach did), but no longer applies to foreach itself. The two loops have different rules, which is one of the few corners of C# where the loop keyword genuinely changes the closure semantics of code written inside it.
A practical mental model: ask yourself whether each iteration creates a fresh variable. For foreach, the answer is yes; for for, the answer is no. If the answer is no, copy the loop variable into a fresh local before the lambda is created, and you'll get the iteration-specific behavior.
Lambdas are everywhere in modern C#, and most of the places they appear are variations on "pass a small piece of behavior into a method that needs it." A non-exhaustive tour of the patterns you'll meet most often:
As arguments to BCL methods that take a `Func`, `Action`, or `Predicate`. List<T>.Find, List<T>.Sort, Array.FindIndex, Task.Run, Parallel.For, and many others accept a delegate parameter. You'll almost always pass a lambda rather than a named method.
Each call hands a small predicate to a method that already knows how to walk the list. The list does the iteration; the lambda answers the question "does this item match?".
As sort comparators. List<T>.Sort(Comparison<T>) and Array.Sort both accept a Comparison<T> delegate. The lambda decides the order, and the BCL handles the rest.
The comparator (a, b) => a.Price.CompareTo(b.Price) lives where the sort happens. If you wanted to sort by name descending instead, you'd change the lambda and nothing else. Reading the call site tells you exactly what the sort order is.
As factory parameters for cached or pooled work. Many APIs (Lazy<T>, ConcurrentDictionary<TKey, TValue>.GetOrAdd, dependency injection containers) accept a factory delegate that runs only when a value is needed. A lambda is the natural way to express "here's how to build one of these."
The factory lambda runs once, on the first .Value access, and Lazy<T> caches the result. The pattern is common enough that any time you see "compute on demand," there's a Func<T> (or similar) parameter accepting a lambda.
As event handlers. Subscribing to an event with a lambda is short and obvious. The downside is that you can't easily unsubscribe (you'd need to keep a reference to the lambda for the -= call), so this style fits one-off subscriptions rather than long-lived ones. The _Events Basics_ and _EventHandler Pattern_ lessons cover the trade-off in detail.
The lambda becomes the body of the handler, and the event invokes it whenever an item is added. No separate method, no extra class, just the behavior next to the code that needs it.
These four patterns (filter/find arguments, sort comparators, factories, event handlers) cover the large majority of lambdas in a typical C# codebase. The rest are variations: a Func<HttpResponseMessage, T> for parsing an HTTP response, an Action<Exception> for a retry policy's error logger, a Func<DbContext> for a unit-of-work factory. The shape is always the same: a method takes behavior as a value, and a lambda is the cheapest way to provide it.