Expression Trees · How it works
6 min readHow it works
Code as data
A: A data structure that represents code. Each node is an object describing one part of an expression. For o => o.Total > 100:
LambdaExpression (o) => ... Type: Func<Order, bool>
└── Body: BinaryExpression NodeType = GreaterThan
├── Left: MemberExpression o.Total Member = Order.Total
│ └── Expression: ParameterExpression o : Order
└── Right: ConstantExpression 100 (decimal)
Because it is data, a program can walk it and do something other than run it: generate SQL (WHERE Total > 100), build a URL ($filter=Total gt 100), produce a property name, or compile it into a delegate.
A: By its target type. The same lambda text compiles two different ways:
Func<Order, bool> f = o => o.Total > 100; // IL method + delegate
Expression<Func<Order, bool>> e = o => o.Total > 100; // code that BUILDS a tree
// What the compiler emits for 'e', roughly:
var o = Expression.Parameter(typeof(Order), "o");
var e = Expression.Lambda<Func<Order, bool>>(
Expression.GreaterThan(
Expression.Property(o, nameof(Order.Total)),
Expression.Constant(100m)),
o);
f can be called; e can be inspected. You cannot call e(order) directly: you would have to Compile() it first.
ParameterExpression(a lambda parameter or variable),ConstantExpression(a value);MemberExpression(field or property access),MethodCallExpression(a method call, includingQueryable.Whereitself);BinaryExpression(+,==,&&,>…),UnaryExpression(!, negation, andConvertcasts);ConditionalExpression(a ? b : c),NewExpression/MemberInitExpression(new Dto { … }),NewArrayExpression;LambdaExpression/Expression<TDelegate>(the root, with parameters and a body);InvocationExpression(invoking another lambda); and, when built by hand,BlockExpression,LoopExpression,AssignExpression,TryExpression.
A: All derive from Expression and expose NodeType and Type:
A: expr.ToString() prints a C#-like form (o => (o.Total > 100)). In the Visual Studio debugger the DebugView property shows the full node structure, including types and closure constants.
Captured variables and closures
A: Not as a constant value but as a field access on the closure object:
decimal min = 100;
Expression<Func<Order, bool>> e = o => o.Total > min;
// Right side: MemberExpression (field 'min') on ConstantExpression (<>c__DisplayClass0_0 instance)
// ToString(): o => (o.Total > value(Program+<>c__DisplayClass0_0).min)
This is exactly what lets EF Core tell "a value supplied by the program" (turn it into a SQL parameter; its current value is read at execution time) from "a literal in the query" (inline it as a constant). See the Closures module for what the display class is.
Compiling trees
Compile() do, and what does it cost?A: It turns the tree into an executable delegate by generating IL at runtime (through a DynamicMethod) and JIT-compiling it. This is expensive: typically tens to hundreds of microseconds and several allocations per call, thousands of times slower than invoking the resulting delegate. The delegate itself then runs about as fast as a normal lambda. Rule: compile once, cache the delegate (for example in a static readonly field or a ConcurrentDictionary keyed by type and member). Compile(preferInterpretation: true) uses an interpreter instead, which starts faster but runs slower; on platforms without JIT (iOS AOT) trees are always interpreted.
A: For a fast, strongly-typed accessor to a member known only at runtime. Reflection's PropertyInfo.GetValue is comparatively slow and boxes value types; a compiled tree is a real delegate as fast as handwritten code after the one-off compile cost. Mappers, serializers and ORMs have long used this trick (newer code may use source generators instead, which move the work to compile time).
static Func<T, object?> CreateGetter<T>(string property)
{
var obj = Expression.Parameter(typeof(T), "obj");
var body = Expression.Convert(Expression.Property(obj, property), typeof(object));
return Expression.Lambda<Func<T, object?>>(body, obj).Compile(); // cache this!
}Reading trees: property selectors
x => x.Email?A: They take an Expression<Func<T, TProperty>> and read the body as a MemberExpression:
static string PropertyName<T, TProp>(Expression<Func<T, TProp>> selector) =>
selector.Body switch
{
MemberExpression m => m.Member.Name,
UnaryExpression { Operand: MemberExpression m } => m.Member.Name, // x => (object)x.Age
_ => throw new ArgumentException("Expected a property access", nameof(selector)),
};
PropertyName<User, string>(u => u.Email); // "Email"
The UnaryExpression case matters: when a value-type property is converted to object, the compiler wraps it in a Convert node. FluentValidation's RuleFor(x => x.Email), EF Core's HasKey(x => x.Id) and Include(o => o.Lines), Moq's Setup(r => r.Get(1)) and AutoMapper's ForMember all rely on this, which gives refactor-safe, compile-checked member references.
Building trees dynamically
A: Use the factory methods to create a parameter, member access, constant and comparison, then wrap them in a lambda. The result can be passed to IQueryable.Where, so it still runs in SQL.
static Expression<Func<T, bool>> Equal<T>(string property, object value)
{
var x = Expression.Parameter(typeof(T), "x");
var member = Expression.Property(x, property); // throws if no such property
var constant = Expression.Constant(value, member.Type);
return Expression.Lambda<Func<T, bool>>(Expression.Equal(member, constant), x);
}
var q = db.Customers.Where(Equal<Customer>("Country", "CY"));
Validate the property name against an allowlist when it comes from user input. For dynamic sorting, build x => x.<Property> and call Queryable.OrderBy through reflection or a small helper; libraries such as System.Linq.Dynamic.Core wrap all of this.
A: Trees are immutable and each lambda has its own ParameterExpression, so you cannot just glue the bodies together: the second body would still refer to the second lambda's parameter. Replace it with an ExpressionVisitor:
static Expression<Func<T, bool>> And<T>(
Expression<Func<T, bool>> a, Expression<Func<T, bool>> b)
{
var p = a.Parameters[0];
var bBody = new ReplaceParameter(b.Parameters[0], p).Visit(b.Body)!;
return Expression.Lambda<Func<T, bool>>(Expression.AndAlso(a.Body, bBody), p);
}
sealed class ReplaceParameter(ParameterExpression from, ParameterExpression to) : ExpressionVisitor
{
protected override Expression VisitParameter(ParameterExpression node) =>
node == from ? to : base.VisitParameter(node);
}
Using Expression.Invoke(b, p) instead compiles, but many providers, EF Core included, cannot translate InvocationExpression, so the parameter-replacing visitor is the portable approach. This is how specification-pattern libraries compose query criteria.
Limits and memory
A: Statement lambdas ({ … } bodies), assignments and ++, the null-conditional ?. and null-coalescing assignment ??=, async lambdas and await, throw expressions, tuple literals and tuple ==, dynamic operations, local functions, ref and out in some forms, and several newer pattern-matching and index/range forms. The expression-tree API itself can represent blocks, loops and assignments (Expression.Block, Expression.Loop, Expression.Assign), but only when you build the tree by hand, and LINQ providers would not translate those anyway.
A: Every node is a separate heap object, and the code the compiler emits for Expression<…> builds the tree each time it runs: unlike a non-capturing lambda delegate, the tree is not cached. So a method that runs a LINQ-to-EF query allocates dozens of small objects per call before any SQL is sent. It is usually negligible next to a database round trip, but it is why EF Core offers EF.CompileQuery for hot paths and why you should cache any tree you build dynamically, and especially any delegate you Compile().
A: Yes to share: they are immutable, so a cached tree or compiled delegate can be used from many threads. "Modifying" a tree with ExpressionVisitor or Update methods always produces new nodes, reusing unchanged subtrees.
Common interview gotchas
Expression<Func<int, int>> like a method?A: It is a data structure, not a delegate. Call Compile() to get a Func<int, int> (and cache it), or use the plain Func<> if you never needed the tree.
Expression<Func<T, bool>> e = x => x?.Name == "A"; fail?A: The null-conditional operator cannot appear in an expression tree (error CS8072). In a database query it is also unnecessary, because SQL comparisons already handle NULL; in memory, write x != null && x.Name == "A".
A: Every IQueryable query (EF Core, MongoDB, OData), EF Core model configuration, strongly-typed property references in validation, mapping and mocking libraries, dynamic filtering and sorting in admin grids, rule engines that store conditions as data, and high-performance accessors in serializers and DI containers.