Delegates, events, lambdas and expression trees are the parts of C# that treat behavior as a value: something you can store, pass around, combine, subscribe to or even inspect as data. They power LINQ, ASP.NET Core Minimal APIs, UI frameworks and EF Core's query translation. This guide is for developers who use lambdas daily but want a precise model of what happens underneath: how delegates and multicast invocation work, the standard .NET event pattern, how closures capture variables, the lambda features added from C# 9 to C# 14, and how expression trees let EF Core translate C# into SQL, with performance notes throughout.
What Are Delegates, Events, Lambdas and Expression Trees?#
The four concepts build on each other:
- A delegate is a type-safe object that references a method, plus the target instance for instance methods. Calling the delegate calls the method.
- A lambda expression such as
x => x * 2is syntax for an anonymous function. On its own it has no type; it converts either to a delegate or to an expression tree. - An event is a class member that wraps a delegate and exposes only subscribe (
+=) and unsubscribe (-=) to the outside world, which gives you a safe publish-subscribe mechanism. - An expression tree is a data structure that describes code. When a lambda is assigned to
Expression<Func<...>>, the compiler emits code that builds a tree of nodes instead of compiled IL, so libraries can analyze it and translate it into something else.
The same lambda text can therefore mean two very different things, depending on its target type. Func<Order, bool> is executable code; Expression<Func<Order, bool>> is a description of that code. That single distinction explains most EF Core surprises.
How Delegates Work in .NET#
When you declare a delegate type, the compiler generates a sealed class derived from System.MulticastDelegate with an Invoke method matching your signature. A delegate instance stores the target object (null for static methods) and a pointer to the method. Delegate instances are immutable: combining or removing handlers always creates a new instance.
In everyday code you rarely declare delegate types, because the generic Func and Action families cover almost every signature. Func<T1, ..., TResult> returns a value, with the result as the last type argument, and Action<T1, ...> returns void. Declare a custom delegate when you need ref, out or params parameters, or when a descriptive name improves an API:
PriceRule rule = Pricing.BulkDiscount; // method group conversion
Func<decimal, int, decimal> sameShape = Pricing.BulkDiscount;
Predicate<int> isEven = n => n % 2 == 0;
Console.WriteLine(Pricing.Apply(rule, 100m, 12)); // 90.0
Console.WriteLine(isEven(4)); // True
// rule = sameShape; // error: delegate types are nominal, identical signatures don't matter
rule = new PriceRule(sameShape); // explicit wrapping works
public delegate decimal PriceRule(decimal price, int quantity);
public static class Pricing
{
public static decimal BulkDiscount(decimal price, int quantity) =>
quantity >= 10 ? price * 0.9m : price;
public static decimal Apply(PriceRule rule, decimal price, int quantity) =>
rule(price, quantity); // shorthand for rule.Invoke(...)
}Generic delegates also support variance: Func<out TResult> is covariant and Action<in T> is contravariant, so a Func<string> can be assigned to a Func<object>. The asynchronous BeginInvoke and EndInvoke methods that .NET Framework offered are not supported on .NET Core and later, where they throw PlatformNotSupportedException; use Task.Run or real asynchronous APIs instead.
Getting Started with Func, Action and Lambdas#
Most delegate usage looks like this: pass a lambda to a method that accepts a Func or Action, and let the compiler infer the parameter types from the target:
var orders = new List<Order>
{
new("A-1", 120m, "NO"),
new("A-2", 35m, "US"),
new("A-3", 410m, "US"),
};
Func<Order, bool> isLarge = o => o.Total >= 100m;
Action<Order> print = o => Console.WriteLine($"{o.Id}: {o.Total:C}");
foreach (var order in orders.Where(isLarge))
{
print(order);
}
var totalsByCountry = orders
.GroupBy(o => o.Country)
.ToDictionary(g => g.Key, g => g.Sum(o => o.Total));
public sealed record Order(string Id, decimal Total, string Country);Enumerable.Where accepts a Func<Order, bool>, so each lambda here compiles to a method, and the delegate simply points at it.
Multicast Delegates#
Every delegate can hold an invocation list. The + and += operators combine delegates, and - and -= remove the last matching occurrence. Invoking a multicast delegate calls each target in order, with two consequences that surprise people: a non-void delegate returns only the last target's result, and the first exception stops the chain. To collect every result or isolate failures, walk the invocation list yourself:
Func<int> providers = () => 1;
providers += () => 2;
providers += () => throw new InvalidOperationException("Provider 3 failed.");
providers += () => 4;
var results = new List<int>();
var errors = new List<Exception>();
// .NET 9 and later: enumerate targets without allocating the Delegate[] array
// that GetInvocationList() returns.
foreach (var provider in Delegate.EnumerateInvocationList(providers))
{
try
{
results.Add(provider());
}
catch (Exception ex)
{
errors.Add(ex);
}
}
Console.WriteLine(string.Join(", ", results)); // 1, 2, 4
Console.WriteLine(errors.Count); // 1Because removal compares the target and the method, -= works for method groups but silently does nothing for a lambda written a second time, which creates a different delegate. Keep a reference to any lambda you intend to remove.
Events and the Standard .NET Event Pattern#
An event restricts what outside code can do with a delegate: subscribers can add and remove handlers, but only the declaring class can raise the event or replace the list. The standard pattern uses EventHandler<TEventArgs>, a sender parameter typed as object?, an arguments type and a protected virtual On... method that raises the event:
namespace Shop.Orders;
public sealed class OrderPlacedEventArgs(Guid orderId, decimal total) : EventArgs
{
public Guid OrderId { get; } = orderId;
public decimal Total { get; } = total;
}
public class OrderService
{
// Field-like event: the compiler generates thread-safe add and remove accessors.
public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;
public void PlaceOrder(Guid orderId, decimal total)
{
// Persist the order, then notify subscribers.
OnOrderPlaced(new OrderPlacedEventArgs(orderId, total));
}
// ?.Invoke reads the delegate once, so a concurrent unsubscribe cannot cause a null call.
protected virtual void OnOrderPlaced(OrderPlacedEventArgs e) => OrderPlaced?.Invoke(this, e);
}
public sealed class ReceiptPrinter : IDisposable
{
private readonly OrderService _orders;
public ReceiptPrinter(OrderService orders)
{
_orders = orders;
_orders.OrderPlaced += OnOrderPlaced; // the publisher now references this object
}
private void OnOrderPlaced(object? sender, OrderPlacedEventArgs e) =>
Console.WriteLine($"Receipt for {e.OrderId}: {e.Total:C}");
// Without this, a long-lived OrderService keeps every printer alive: a classic leak.
public void Dispose() => _orders.OrderPlaced -= OnOrderPlaced;
}A few details matter in production code. Make event argument types immutable so one subscriber cannot change what the next one sees, unless you deliberately use a mutable flag such as a cancellation request. Since .NET Core, EventHandler<TEventArgs> no longer requires TEventArgs to derive from EventArgs, although deriving remains the convention. You can write explicit add and remove accessors when you need custom storage, and C# 14 allows partial events, so a source generator can supply those accessors for you. Handlers that call asynchronous code must be async void, so wrap their bodies in try/catch: an exception escaping an async void handler cannot be observed by the publisher and can crash the process.
The biggest operational risk is lifetime. An event source holds strong references to all its subscribers, so a singleton publisher with short-lived subscribers leaks memory unless subscribers unsubscribe, typically in Dispose. For more on diagnosing such leaks, see finding and fixing memory leaks.
Closures and Captured Variables#
A lambda that uses a local variable or parameter from its enclosing method captures that variable, not its current value. The compiler moves captured variables into a hidden closure class, so their lifetime extends to the lifetime of the delegate, and changes made inside or outside the lambda are visible to both sides. Loop variables are the classic trap:
var actions = new List<Action>();
for (var i = 0; i < 3; i++)
{
actions.Add(() => Console.Write(i)); // one variable i shared by all iterations
}
actions.ForEach(a => a()); // prints 333
actions.Clear();
foreach (var n in new[] { 0, 1, 2 })
{
actions.Add(() => Console.Write(n)); // each iteration has its own n (since C# 5)
}
actions.ForEach(a => a()); // prints 012Capturing also costs allocations: the closure object is created when the scope that declares the captured variables is entered, and a new delegate instance is created each time the lambda expression is evaluated. Lambdas that capture nothing are different, because the compiler caches their delegate in a static field and reuses it. The static modifier (C# 9) makes that intent explicit and turns any accidental capture, including this, into a compile error. Many APIs accept a state argument so hot paths can stay allocation-free:
using System.Collections.Concurrent;
public sealed class TenantCache(string connectionString)
{
private readonly ConcurrentDictionary<string, Tenant> _tenants = new();
// Captures `this` (for connectionString): allocates a delegate on every call.
public Tenant GetCapturing(string id) =>
_tenants.GetOrAdd(id, key => Tenant.Load(key, connectionString));
// Static lambda plus factoryArgument: no capture, the delegate is cached.
public Tenant Get(string id) =>
_tenants.GetOrAdd(id, static (key, cs) => Tenant.Load(key, cs), connectionString);
}
public sealed record Tenant(string Id, string Region)
{
public static Tenant Load(string id, string connectionString) => new(id, "eu-west");
}Modern Lambda Features: Natural Types, Attributes and Default Parameters#
Lambdas gained several capabilities in recent language versions:
| Feature | C# version | Example |
|---|---|---|
| Lambdas and expression trees | C# 3 | x => x * 2 |
static lambdas and discard parameters | C# 9 | static (_, _) => 0 |
| Natural type, explicit return type, attributes | C# 10 | var parse = (string s) => int.Parse(s); |
| Cached delegates for static method groups | C# 11 | Func<int, int> abs = Math.Abs; |
Default parameter values and params | C# 12 | (int retries = 3) => retries |
| Modifiers without explicit types | C# 14 | (text, out value) => int.TryParse(text, out value) |
var parse = (string s) => int.Parse(s); // natural type: Func<string, int>
var choose = object (bool flag) => flag ? 1 : "one"; // explicit return type
var greet = (string name, string greeting = "Hello") => $"{greeting}, {name}!";
var total = (params decimal[] amounts) => amounts.Sum();
Console.WriteLine(parse("42") + 1); // 43
Console.WriteLine(greet("Ada")); // Hello, Ada!
Console.WriteLine(total(9.99m, 5m)); // 14.99
// C# 14: modifiers such as out no longer force you to spell out parameter types.
TryParse<int> tryParse = (text, out value) => int.TryParse(text, out value);
Console.WriteLine(tryParse("7", out var seven) ? seven : -1); // 7
delegate bool TryParse<T>(string text, out T value);A lambda gets a natural type only when the compiler can infer everything: var f = x => x; is still an error. Lambdas with default values or params get a compiler-synthesized delegate type rather than a Func. Attributes on lambdas exist mainly for frameworks that inspect them through reflection. ASP.NET Core Minimal APIs are the prime example: app.MapGet("/admin", [Authorize] () => ...) applies authorization metadata to the endpoint. Attributes do nothing when the delegate is invoked, and the Minimal APIs guide shows how endpoint metadata is used.
Expression Trees: Code as Data#
Assigning a lambda to Expression<TDelegate> makes the compiler build a tree of System.Linq.Expressions nodes instead of compiling the body. You can walk the tree, rewrite it, translate it to another language, or compile it into a delegate at runtime:
using System.Linq.Expressions;
Func<int, bool> isAdultCode = age => age >= 18; // compiled IL
Expression<Func<int, bool>> isAdultData = age => age >= 18; // a description of the code
var body = (BinaryExpression)isAdultData.Body;
Console.WriteLine(body.NodeType); // GreaterThanOrEqual
Console.WriteLine(body.Left); // age
Console.WriteLine(body.Right); // 18
Func<int, bool> compiled = isAdultData.Compile(); // expensive: compile once and cache
Console.WriteLine(compiled(21)); // TrueExpression trees are immutable, so modifications mean building a new tree, usually with an ExpressionVisitor. They also have firm limits. The compiler only converts expression-bodied lambdas, never statement bodies, and many newer constructs cannot appear inside an expression tree: ?., await, throw expressions, tuple literals, collection expressions, local functions and pattern matching with is or switch expressions among them. In EF Core queries this is why o.Customer != null compiles while o.Customer is not null does not.
You can also build trees by hand, which is how libraries implement dynamic sorting and filtering. Validate any user-supplied member name against an allow-list first:
using System.Linq.Expressions;
public static class QueryableSorting
{
// Builds source.OrderBy(x => x.<propertyName>) at runtime.
public static IOrderedQueryable<T> OrderByProperty<T>(
this IQueryable<T> source, string propertyName, bool descending = false)
{
var parameter = Expression.Parameter(typeof(T), "x");
var property = Expression.Property(parameter, propertyName); // throws if missing
var keySelector = Expression.Lambda(property, parameter);
var call = Expression.Call(
typeof(Queryable),
descending ? nameof(Queryable.OrderByDescending) : nameof(Queryable.OrderBy),
[typeof(T), property.Type],
source.Expression,
Expression.Quote(keySelector));
return (IOrderedQueryable<T>)source.Provider.CreateQuery<T>(call);
}
}LambdaExpression.Compile() generates IL through the runtime's dynamic code support, so it is relatively expensive: compile once and cache the delegate. Under Native AOT there is no runtime code generation, and compiled expressions always run in their interpreted form, which is noticeably slower. Compile(preferInterpretation: true) requests that interpreted form explicitly when compile cost matters more than execution speed.
How EF Core Uses Expression Trees#
IQueryable<T> operators such as Queryable.Where take Expression<Func<T, bool>> parameters, while their IEnumerable<T> counterparts take Func<T, bool>. The compiler picks the overload from the static type of the source, and that choice decides whether EF Core can translate the lambda into SQL:
// IQueryable<Order>: the lambda becomes an expression tree and EF Core emits a SQL WHERE.
var recent = await db.Orders
.Where(o => o.PlacedAt >= since)
.ToListAsync(cancellationToken);
// AsEnumerable() switches to Enumerable.Where: every row is loaded, then filtered in memory.
var slow = db.Orders.AsEnumerable().Where(o => o.PlacedAt >= since).ToList();
// Captured variables become SQL parameters, so one cached query plan serves every value.
// Literal constants are embedded in the SQL and in EF Core's query cache key instead.
var status = OrderStatus.Pending;
var pending = await db.Orders
.Where(o => o.Status == status)
.ToListAsync(cancellationToken);EF Core caches translated queries by the shape of the expression tree, which is why parameterization matters: trees that differ only in embedded constants are compiled separately and can pollute the query cache. For the hottest queries, EF.CompileAsyncQuery (or EF.CompileQuery) compiles the translation once into a thread-safe delegate that skips the cache lookup entirely:
using Microsoft.EntityFrameworkCore;
public static class OrderQueries
{
private static readonly Func<ShopContext, Guid, IAsyncEnumerable<Order>> ByCustomer =
EF.CompileAsyncQuery((ShopContext db, Guid customerId) =>
db.Orders
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.PlacedAt));
public static IAsyncEnumerable<Order> ForCustomer(ShopContext db, Guid customerId) =>
ByCustomer(db, customerId);
}When filters are optional, compose them by chaining Where calls on the IQueryable<T> rather than building trees by hand. When you do need to combine predicates, for example from a rules engine, rewrite parameters with an ExpressionVisitor so the result stays translatable:
using System.Linq.Expressions;
public static class PredicateComposition
{
public static Expression<Func<T, bool>> AndAlso<T>(
this Expression<Func<T, bool>> left, Expression<Func<T, bool>> right)
{
var parameter = left.Parameters[0];
var rightBody = new ParameterReplacer(right.Parameters[0], parameter).Visit(right.Body);
return Expression.Lambda<Func<T, bool>>(
Expression.AndAlso(left.Body, rightBody), parameter);
}
private sealed class ParameterReplacer(ParameterExpression source, ParameterExpression target)
: ExpressionVisitor
{
protected override Expression VisitParameter(ParameterExpression node) =>
node == source ? target : base.VisitParameter(node);
}
}Calling Compile() on the combined predicate would also work for in-memory filtering, but passing the expression to Where keeps the whole filter on the database side. The EF Core performance guide covers compiled queries and caching in more depth.
Performance Notes for Delegates and Lambdas#
- Invocation cost. A delegate call is an indirect call, which the JIT usually cannot inline. Since .NET 7 the JIT can use profile data to guard-devirtualize delegate calls with a dominant target, and dynamic PGO is enabled by default since .NET 8, so hot, monomorphic call sites often get cheaper automatically.
- Allocation. Non-capturing lambdas and, since C# 11, static method group conversions reuse cached delegate instances. Capturing lambdas allocate a closure and a delegate. In hot paths, use
staticlambdas with state parameters. - Events. Raising an event with no subscribers costs a null check. Many subscribers mean a multicast invocation; if handlers are slow, consider a queue or
System.Threading.Channelsinstead. DynamicInvoke. It goes through reflection and boxes arguments, so avoid it on hot paths and prefer strongly typed invocation.- Expression compilation. Compiling a tree costs far more than invoking the result. Cache compiled delegates, and expect interpretation under Native AOT.
Best Practices#
- Prefer
FuncandActionunless a custom delegate adds a meaningful name or needsref,outorparams. - Follow the standard event pattern. Use
EventHandler<TEventArgs>, immutable arguments and a protected virtualOn...method, and raise with?.Invoke. - Unsubscribe deterministically. Tie subscriptions to
IDisposablewhen the publisher outlives the subscriber. - Mark lambdas
staticwhen they need no state. It documents intent and prevents accidental allocations. - Keep
IQueryable<T>until the query is complete. CallAsEnumerableorToListonly after everything translatable has been composed. - Cache compiled expressions and EF Core compiled queries in static readonly fields.
Common Pitfalls#
- Capturing loop variables in
forloops. Copy the value into a local inside the loop before capturing. - Relying on a multicast return value. Only the last handler's result is returned.
- Removing a lambda that was never stored.
-=with a new lambda instance removes nothing. - Unhandled exceptions in
async voidhandlers. Catch and log inside every async event handler. - Accidental client-side evaluation. A
Funcparameter or an earlyAsEnumerable()moves filtering into memory. - Constants in dynamically built trees. They defeat EF Core's query cache; wrap values in a closure or parameter instead.
Delegates vs Events vs Interfaces vs Expression Trees#
| Mechanism | Best for | Who can invoke | Trade-offs |
|---|---|---|---|
Delegate (Func, Action) | Passing a single operation, callbacks, strategies | Anyone holding the reference | Lightweight; no named contract |
| Event | Notifying zero or more subscribers | Only the declaring class | Lifetime coupling; async void handlers |
| Interface | Several related operations, dependency injection | Anyone holding the reference | More ceremony; easier to mock and document |
| Expression tree | Translating or analyzing code, such as LINQ providers | After Compile(), or by a provider | Limited syntax; compile cost |
Frequently Asked Questions#
What is the difference between a delegate and an event in C#?#
A delegate is a type-safe reference to one or more methods that anyone holding it can invoke or replace. An event wraps a delegate field and only exposes subscribe and unsubscribe to outside code, so only the declaring class can raise it. Use events for notifications and delegates for passing behavior into a method.
Why does my lambda in a for loop print the same value every time?#
The lambda captures the loop variable itself, and a for loop declares a single variable shared by all iterations. By the time the lambdas run, the variable holds its final value. Copy it into a local inside the loop, or use foreach, which creates a fresh variable per iteration since C# 5.
When should I use a static lambda?#
Use static whenever a lambda does not need locals, parameters or this from the enclosing scope. The compiler then guarantees there is no capture, which avoids closure allocations and prevents accidental dependencies on outer state.
What is the difference between Func and Expression of Func?#
A Func<T, TResult> is compiled code you can execute directly. An Expression<Func<T, TResult>> is a data structure describing that code, which LINQ providers like EF Core translate into SQL. The same lambda converts to either one depending on the target type.
Are expression trees slow?#
Building and inspecting a small tree is cheap, but Compile() generates IL at runtime and is expensive, so cache the resulting delegate. Under Native AOT, compiled expressions run in an interpreter and are slower still, which matters for libraries that compile expressions heavily.
Summary#
- Delegates are immutable, type-safe method references;
FuncandActioncover most needs. - Multicast invocation returns the last result and stops at the first exception; walk the invocation list for control.
- Events add encapsulation to delegates; follow the standard pattern and unsubscribe to avoid leaks.
- Closures capture variables, not values;
staticlambdas prevent capture and allocations. - Expression trees represent code as data, which is how EF Core translates LINQ into SQL; keep queries parameterized and cache compiled delegates.