Expression Trees
Key Points
- An expression tree is code-as-data.
Expression<Func<T, bool>>is a lambda represented as a tree ofExpressionnodes, not a delegate. - Convert with
.Compile()to a delegate (allocates IL viaReflection.Emit— incompatible with NativeAOT). - LINQ providers (EF Core, IQueryable) walk expression trees to translate to SQL/CQL/etc.
- Build expression trees programmatically with
Expression.Lambda,Expression.Property,Expression.Equal, etc. ExpressionVisitoris the canonical pattern for transforming an existing tree (e.g., parameter substitution).- Caching compiled delegates is essential —
.Compile()is expensive (allocates IL, boxes generators). - NativeAOT incompatibility: expression-tree compilation requires JIT/ReflectionEmit. Find compile-time alternatives (source generators).
Concepts (deep dive)
Lambda → expression tree
// Func<int, bool> — a delegate; runtime invocable
Func<int, bool> f = x => x > 5;
bool b = f(10); // calls the compiled lambda
// Expression<Func<int, bool>> — an expression tree; not invocable
Expression<Func<int, bool>> e = x => x > 5;
// e.Body is a BinaryExpression: GreaterThan
// e.Parameters is an array with one ParameterExpression (the "x")
The compiler picks based on the target type. If you assign to Func<...>, it's a delegate. If you assign to Expression<Func<...>>, it's a tree.
Tree structure
For x => x.Age > 18 && x.IsActive:
LambdaExpression
/ Parameters: [x: Customer]
/ Body:
AndAlso
/ \
GreaterThan MemberAccess (x.IsActive)
/ \
MemberAccess Constant(18)
(x.Age)
Each node is an Expression subclass: - LambdaExpression — the whole lambda - BinaryExpression (&&, >, +) - MemberExpression (x.Age) - MethodCallExpression (x.ToString()) - ConstantExpression (18) - ParameterExpression (x) - UnaryExpression (-x, !x, casts) - NewExpression (new T(...)) - MemberInitExpression (new T { X = 1 })
.Compile()
Expression<Func<int, bool>> e = x => x > 5;
Func<int, bool> f = e.Compile(); // generates IL via ReflectionEmit, boxed into a delegate
bool b = f(10);
.Compile() is expensive: LINQ tree walk → IL generation → JIT. Microseconds per call. Cache the result if you'll invoke repeatedly:
private static readonly Func<int, bool> _isOver5 = ((Expression<Func<int, bool>>)(x => x > 5)).Compile();
There's also .CompileToMethod (older, uses ReflectionEmit.MethodBuilder) and .CompileToInterpreter (slow but doesn't need IL emit — useful when JIT isn't available).
How EF Core uses trees
db.Customers.Where(c => c.Age > 18 && c.IsActive)
.Select(c => new { c.Id, c.Name })
.ToListAsync();
EF doesn't invoke the lambda. It receives the expression tree and:
- Walks it via an
ExpressionVisitor-based translator. - Recognizes patterns:
MemberAccessonCustomer.Age→ SQLAgecolumn;Constant→ SQL parameter. - Emits SQL:
SELECT [Id], [Name] FROM Customers WHERE Age > 18 AND IsActive = 1.
Building trees by hand
var paramX = Expression.Parameter(typeof(int), "x");
var body = Expression.GreaterThan(paramX, Expression.Constant(5));
var lambda = Expression.Lambda<Func<int, bool>>(body, paramX);
Func<int, bool> compiled = lambda.Compile();
Console.WriteLine(compiled(10)); // True
Used by: - ORMs (constructing query trees from spec objects). - Mapping libraries (Mapster, AutoMapper for non-AOT builds). - Validators that compose lambdas at runtime.
⚠️ NativeAOT incompatible.
.Compile()callsReflection.Emit— banned in AOT. Use source generators instead (compile-time codegen).
ExpressionVisitor — the canonical traversal
public class ParameterReplacer : ExpressionVisitor
{
private readonly ParameterExpression _from;
private readonly Expression _to;
public ParameterReplacer(ParameterExpression from, Expression to)
{
_from = from; _to = to;
}
protected override Expression VisitParameter(ParameterExpression node)
=> node == _from ? _to : base.VisitParameter(node);
}
// Usage: combine two predicates
public static Expression<Func<T, bool>> AndAlso<T>(
this Expression<Func<T, bool>> a,
Expression<Func<T, bool>> b)
{
var param = a.Parameters[0];
var bodyB = new ParameterReplacer(b.Parameters[0], param).Visit(b.Body)!;
var combined = Expression.AndAlso(a.Body, bodyB);
return Expression.Lambda<Func<T, bool>>(combined, param);
}
This is how libraries like LINQKit and EF Core's specifications combine predicates.
Caching compiled trees
private static readonly ConcurrentDictionary<string, Delegate> _cache = new();
public static Func<T, bool> GetPredicate<T>(string spec)
=> (Func<T, bool>)_cache.GetOrAdd(spec, key => BuildPredicate<T>(key).Compile());
Without caching, every request that builds the same predicate pays the IL-emit cost. With caching, the cost is paid once.
Expression<T>.ToString() for debugging
Useful for logging dynamic predicates or verifying construction.
Expression.PropertyOrField, Expression.Call
var paramT = Expression.Parameter(typeof(Customer), "c");
var nameProp = Expression.Property(paramT, nameof(Customer.Name));
var lambda = Expression.Lambda<Func<Customer, string>>(nameProp, paramT);
Func<Customer, string> getName = lambda.Compile();
Programmatic property access — the building block for runtime-built mappers.
How it works under the hood
Expression<TDelegate> is a class hierarchy in System.Linq.Expressions:
Expression
├ ParameterExpression
├ ConstantExpression
├ UnaryExpression
├ BinaryExpression
├ MemberExpression
├ MethodCallExpression
├ LambdaExpression (subclass: Expression<TDelegate>)
├ ...
.Compile() invokes a runtime that walks the tree and emits CIL via System.Reflection.Emit.DynamicMethod. The result is a delegate pointing at JIT-compiled native code.
EF Core and similar providers walk the same tree but emit target-language code instead of CIL — SQL strings, MongoDB query documents, CQL, etc. The translator is a series of ExpressionVisitor subclasses cooperating.
Trees are immutable. To "modify", you produce a new tree using ExpressionVisitor overrides that return modified replacements; unmodified subtrees are reused.
Code: correct vs wrong
❌ Wrong: invoking .Compile() per call
public Func<T, bool> Build<T>(string field, object value)
{
var p = Expression.Parameter(typeof(T));
var prop = Expression.Property(p, field);
var c = Expression.Constant(value);
var eq = Expression.Equal(prop, c);
return Expression.Lambda<Func<T, bool>>(eq, p).Compile();
// ❌ Compile every call; expensive
}
✅ Correct: cache by spec
private static readonly ConcurrentDictionary<(Type T, string field), Delegate> _cache = new();
public Func<T, bool> Build<T>(string field, object value)
{
var key = (typeof(T), field);
var template = (Func<T, object, bool>)_cache.GetOrAdd(key, _ => BuildTemplate<T>(field));
return x => template(x, value);
}
private static Delegate BuildTemplate<T>(string field)
{
var p = Expression.Parameter(typeof(T));
var v = Expression.Parameter(typeof(object), "v");
var prop = Expression.Property(p, field);
var converted = Expression.Convert(v, prop.Type);
var eq = Expression.Equal(prop, converted);
return Expression.Lambda<Func<T, object, bool>>(eq, p, v).Compile();
}
❌ Wrong: combining two Expression<> lambdas naively
Expression<Func<T, bool>> a = x => x.Age > 18;
Expression<Func<T, bool>> b = x => x.IsActive;
var combined = Expression.AndAlso(a.Body, b.Body); // ❌ different ParameterExpression instances for x
a.Parameters[0] and b.Parameters[0] are different ParameterExpression objects. Using both in one lambda produces invalid trees.
✅ Correct: use ExpressionVisitor to unify parameters
See "ExpressionVisitor" pattern above.
❌ Wrong: using Expression.Compile() in NativeAOT
✅ Correct: source generator or static lambda
For EF Core models on AOT: use the compiled-models source generator (dotnet ef dbcontext optimize).
Design patterns for this topic
Pattern 1 — "Specification with Expression<Func<T, bool>>"
- Intent: represent a query criterion as a value object that EF Core can translate.
- Code sketch:
public abstract record Spec<T>
{
public abstract Expression<Func<T, bool>> ToExpression();
public Spec<T> And(Spec<T> other) => new AndSpec<T>(this, other);
}
Pattern 2 — "Compiled delegate cache"
- Intent: avoid
Compile()overhead per call. - Code sketch: see "correct vs wrong" #1.
Pattern 3 — "Source-generated alternative"
- Intent: AOT-safe code generation in place of expression-tree compilation.
- Trade-offs: more complex tooling but works in AOT.
Pattern 4 — "Fluent builders that yield expression trees"
- Intent: type-safe specification building.
- Code sketch:
public class FilterBuilder<T>
{
private Expression<Func<T, bool>> _filter = x => true;
public FilterBuilder<T> Where(Expression<Func<T, bool>> p)
=> new() { _filter = _filter.AndAlso(p) };
public Expression<Func<T, bool>> Build() => _filter;
}
Pattern 5 — "Visitor-based parameter substitution"
- Intent: combine lambdas with different parameter instances.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Expression trees | Code-as-data; queryable | Heavyweight; not AOT-safe |
.Compile() | Generates fast native code | High first-call cost |
ExpressionVisitor | Standard transformation API | Verbose |
| Source-gen alternative | AOT-safe; no per-call cost | Build-time complexity |
When to use / when to avoid
- Use expression trees when you need code-as-data: ORM queries, dynamic predicates, mapping.
- Cache compiled delegates —
.Compile()is expensive. - Avoid in NativeAOT — use source generators instead.
- Avoid building trees by hand unless you're writing a library — most use cases are served by lambdas the compiler emits.
Interview Q&A
Q1. What's the difference between Func<T> and Expression<Func<T>>? Func<T> is a compiled delegate — invocable. Expression<Func<T>> is an expression tree — code as data. Compiler generates one or the other based on the target type.
Q2. How does EF Core use expression trees? Receives Expression<Func<T, bool>> from Where(...) calls; walks the tree via an ExpressionVisitor-based translator; emits SQL targeting your database.
Q3. Why is .Compile() expensive? It walks the expression tree, generates IL via Reflection.Emit, and produces a DynamicMethod JITted to native code. Microseconds per call — significant if you Compile() per request.
Q4. Why doesn't expression-tree compilation work in NativeAOT? Because it requires Reflection.Emit — runtime IL generation. NativeAOT has no JIT and forbids ReflectionEmit. Use source generators for compile-time codegen instead.
Q5. What's ExpressionVisitor for? The canonical pattern for transforming an expression tree. Override Visit* methods for the node types you care about; base.Visit recurses for the rest.
Q6. How do you combine two Expression<Func<T, bool>> predicates? Naively combining bodies fails because the parameters are different instances. Use ExpressionVisitor to substitute one parameter for the other. Libraries like LINQKit do this.
Q7. What's Expression.Constant vs Expression.Parameter? Constant is a literal value baked into the tree. Parameter is a placeholder for a runtime value (the lambda's argument).
Q8. When would you build an expression tree at runtime? Dynamic filtering ("user provided this WHERE clause"), runtime mapping, fluent builders that produce ORM-translatable predicates.
Q9. How does Expression<TDelegate> differ from LambdaExpression? LambdaExpression is the non-generic base; Expression<TDelegate> is the strongly-typed subclass. Expression<Func<int, bool>> carries the delegate type for type safety.
Q10. What's the cost of .Compile() on subsequent invocations? The first call generates native code. Subsequent invocations of the resulting delegate are cheap — like any compiled lambda.
Q11. Can you serialize an expression tree? Not natively. Some libraries (e.g., Serialize.Linq) build serializers, but it's a complex project. Usually better to define a domain-specific spec object and translate to Expression<> at use.
Q12. What's the relationship between source generators and expression trees? Both produce code. Expression trees do it at runtime via JIT/ReflectionEmit; source generators do it at compile time. For AOT and modern .NET, source generators are preferred.
Gotchas / common mistakes
- ⚠️
.Compile()per call — measurable perf hit. - ⚠️ Combining two lambdas without ExpressionVisitor — parameter mismatch.
- ⚠️ NativeAOT +
Compile()— runtime exception. - ⚠️ Custom helper methods inside
IQueryable.Where— EF can't translate. - ⚠️ Closure capture in expression tree — captured variables become
ConstantExpression(with the value snapshot). - ⚠️ Forgetting tree immutability — to modify, build a new tree.