Expression Trees · How it works

6 min read
Senior10 min read
Rapid overview

How it works

Code as data

Q: What is an expression tree?

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.

Q: How does a lambda become an expression tree?

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, including Queryable.Where itself);
  • BinaryExpression (+, ==, &&, >…), UnaryExpression (!, negation, and Convert casts);
  • 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.
Q: What are the main node types?

A: All derive from Expression and expose NodeType and Type:

Q: How do you view a tree while debugging?

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

Q: How does a captured variable appear in an expression tree?

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

Q: What does 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.

Q: Why build and compile a tree instead of using reflection?

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

Q: How do libraries get a property name from 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

Q: How do you build a filter at runtime, for example from a search form?

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.

Q: How do you combine two predicates with AND?

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

Q: Which lambdas can the compiler not convert to an expression tree?

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.

Q: What does an expression tree cost in memory?

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().

Q: Are expression trees thread-safe?

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

Q: Why can't you invoke an 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.

Q: Why does 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".

Q: Where do expression trees appear in everyday .NET code?

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.

See also