Expression trees: code as data, compiler-generated trees from lambdas, building and visiting trees, combining predicates, Compile cost and caching, interpretation under Native AOT, and language limitations.
An expression tree is code represented as data. If you assign a lambda to Expression<Func<Order,bool>>, the compiler builds an object tree of parameter, member, binary and constant nodes instead of IL, and that is what lets EF Core read your Where and turn it into SQL. You can build trees with the Expression factory methods, rewrite them with ExpressionVisitor, and turn them into delegates with Compile(). Compiling is expensive, so cache the result, and under Native AOT it falls back to a slower interpreter. Only expression-bodied lambdas qualify, and features like ?., tuple literals and switch expressions are not allowed.
An expression tree is a data structure that represents code — a tree of Expression nodes (parameters, member access, method calls, binary operators). The C# compiler can produce one from a lambda, and libraries can inspect, transform, translate or compile it at run time. It's the foundation of EF Core, OData, dynamic filtering, and many mapping and validation libraries.
Expression<TDelegate>Expression<Func<Order, bool>> isLarge = o => o.Total > 1000m && o.Status == OrderStatus.Paid;
Because the target type is Expression<...>, the compiler emits code that builds the tree instead of compiling a method:
Lambda
└── AndAlso
├── GreaterThan(Member(o.Total), Constant(1000m))
└── Equal(Member(o.Status), Constant(Paid))
Captured variables appear as member accesses on closure objects (which EF Core turns into SQL parameters).
Inspect:
if (isLarge.Body is BinaryExpression { NodeType: ExpressionType.AndAlso } and) { /* walk and.Left, and.Right */ }
For real work, derive from ExpressionVisitor to traverse or rewrite trees.
Build dynamically (e.g. a filter from query-string fields):
static Expression<Func<T, bool>> PropertyEquals<T>(string property, object value)
{
var p = Expression.Parameter(typeof(T), "x");
var member = Expression.PropertyOrField(p, property); // x.Status
var constant = Expression.Constant(Convert.ChangeType(value, member.Type), member.Type);
return Expression.Lambda<Func<T, bool>>(Expression.Equal(member, constant), p);
}
var filter = PropertyEquals<Order>("Region", "NSW");
var orders = await db.Orders.Where(filter).ToListAsync(ct); // translated to SQL
(Always allow-list property names from user input.)
Combine predicates (common need): And(a, b) requires rebinding parameters with a visitor so both sides use the same ParameterExpression.
Compile:
Func<Order, bool> compiled = isLarge.Compile();
x => x.Email used by FluentValidation (RuleFor(x => x.Email)), EF Core configuration (builder.Property(o => o.Total)), Moq setups (mock.Setup(s => s.Get(It.IsAny<int>()))), AutoMapper/Mapperly config — the library reads member names and types from the tree.PropertyInfo.GetValue per call; modern .NET's reflection is faster, and source generators are better still).The compiler rejects (CS8xxx errors) or doesn't support in expression lambdas:
if statements, local variable declarations) — only expression lambdas.=, +=), ++/--.async/await, yield.?. (CS8072), throw expressions, switch expressions, pattern matching is patterns beyond simple type tests, tuple literals, interpolated strings (in older versions, partial support), index/range ^/.. in some contexts, default literal, ref locals/returns, local functions, dynamic.The System.Linq.Expressions API itself has nodes for blocks, loops and assignments (from the DLR), but the C# compiler won't generate them from lambdas, and LINQ providers wouldn't translate them anyway. Recent C# versions have been relaxing a few restrictions, but most remain because providers can't translate them.
Compile()Compile() generates IL via DynamicMethod/Reflection.Emit and JIT-compiles it — expensive (tens to hundreds of microseconds or more per call). Cache compiled delegates; never compile per request.Compile() falls back to an interpreter (Compile(preferInterpretation: true) behaviour) — orders of magnitude slower; APIs using it get RequiresDynamicCode warnings. Libraries targeting AOT replace expression compilation with source generators.An admin grid supported filtering on any column, implemented with string-built SQL — a SQL injection risk. We replaced it with an allow-listed set of filterable properties and an expression builder producing Expression<Func<Customer, bool>> combined with AndAlso, passed to EF Core. Filters became parameterised SQL, injection was eliminated, and the same expressions were reused for count queries. A first version called Compile() per row for in-memory highlighting — caching the compiled delegate per filter fixed a CPU spike.
An advanced question that shows whether the candidate understands what EF Core actually receives.
Green flags: code as data; node types; how captured variables become parameters; ExpressionVisitor for combining; Compile cost and caching; AOT interpreter; knows language limitations; safe dynamic filters.
Red flags: thinks expression trees are delegates; calls Compile per request; builds SQL strings for dynamic filters.
Key classes in System.Linq.Expressions: ParameterExpression, ConstantExpression, MemberExpression, MethodCallExpression, BinaryExpression, UnaryExpression (including Convert, Quote), LambdaExpression/Expression<T>, NewExpression, MemberInitExpression, ConditionalExpression, InvocationExpression, TypeBinaryExpression. Each has a NodeType (ExpressionType enum). Trees are immutable; visitors create modified copies.
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 : node;
}
Using Expression.Invoke(b, p) instead also works for in-memory compilation but many providers (older EF versions) don't translate Invoke well.
var min = 100m;
Expression<Func<Order, bool>> e = o => o.Total > min;
// Body: GreaterThan(Member(o.Total), Member(Constant(<>c__DisplayClass), "min"))
EF Core recognises "member access on a closure constant" and extracts it as a parameter value, enabling plan reuse. A literal 100m instead becomes a SQL literal.
EF Core computes a structural hash of the tree (ignoring parameter values) to look up compiled queries. Dynamically building trees that embed constants for user values creates a new cache entry (and SQL plan) per value — use closure captures or Expression.Constant wrapped in a closure-like holder to get parameters.
MethodInvoker/UnsafeAccessor (.NET 8) for fast member access.Expression<Func<Order,bool>> trees with .ToString() and walk them with a custom ExpressionVisitor.ToQueryString().Compile() once vs per call, and test under NativeAOT to see interpretation.using System.Linq.Expressions;
namespace Linq.ExpressionTrees;
public sealed record Order(int Id, string Status, decimal Total, string Region);
public static class PredicateBuilder
{
public static Expression<Func<T, bool>> And<T>(this Expression<Func<T, bool>> left, Expression<Func<T, bool>> right)
{
var parameter = left.Parameters[0];
var rightBody = new ReplaceParameter(right.Parameters[0], parameter).Visit(right.Body)!;
return Expression.Lambda<Func<T, bool>>(Expression.AndAlso(left.Body, rightBody), parameter);
}
private sealed class ReplaceParameter(ParameterExpression from, ParameterExpression to) : ExpressionVisitor
{
protected override Expression VisitParameter(ParameterExpression node) => node == from ? to : base.VisitParameter(node);
}
}
public static class FilterFactory
{
private static readonly HashSet<string> AllowedFields = [nameof(Order.Status), nameof(Order.Region), nameof(Order.Total)];
// Build x => x.Field == value from user input, safely (whitelisted members, constant values)
public static Expression<Func<Order, bool>> Equal(string field, object value)
{
if (!AllowedFields.Contains(field)) throw new ArgumentException($"Field '{field}' cannot be filtered.", nameof(field));
var x = Expression.Parameter(typeof(Order), "x");
var member = Expression.Property(x, field);
var constant = Expression.Constant(Convert.ChangeType(value, member.Type), member.Type);
return Expression.Lambda<Func<Order, bool>>(Expression.Equal(member, constant), x);
}
}
public static class Program
{
// Compile once, reuse
private static readonly Func<Order, bool> IsLarge = ((Expression<Func<Order, bool>>)(o => o.Total > 1_000m)).Compile();
public static void Main()
{
Expression<Func<Order, bool>> open = o => o.Status == "Open";
var filter = open.And(FilterFactory.Equal("Region", "AU")).And(o => o.Total > 100m);
Console.WriteLine(filter); // o => (((o.Status == "Open") AndAlso (o.Region == "AU")) AndAlso (o.Total > 100))
List<Order> orders = [new(1, "Open", 250m, "AU"), new(2, "Open", 80m, "AU"), new(3, "Closed", 5_000m, "NZ")];
var matches = orders.AsQueryable().Where(filter).Select(o => o.Id).ToList(); // with EF Core: db.Orders.Where(filter)
Console.WriteLine(string.Join(",", matches)); // 1
Console.WriteLine(IsLarge(orders[2])); // True
// Expression<Func<Order, int?>> bad = o => o.Status?.Length; // CS8072: no ?. in expression trees
}
}
What happens: expr.Compile() inside a request or loop.
Why it is wrong: expensive code generation each time.
Do instead: cache compiled delegates.
What happens: property names from query strings used directly. Why it is wrong: exposes arbitrary properties or causes runtime errors. Do instead: allow-list filterable properties.
What happens: Expression.Constant(userValue) in EF queries.
Why it is wrong: new SQL text and cache entry per value; plan cache bloat.
Do instead: capture values in closures so EF uses parameters.
Expression.InvokeWhat happens: predicates combined via Invoke.
Why it is wrong: some providers can't translate it.
Do instead: rebind parameters with a visitor.
What happens: ?., switch expressions or statement lambdas in expression lambdas.
Why it is wrong: compile errors.
Do instead: use translatable constructs (conditional operators, explicit null checks).
Compile() in NativeAOT appsWhat happens: expression compilation in AOT builds. Why it is wrong: falls back to slow interpretation; warnings ignored. Do instead: use source generators or precompiled delegates.
What happens: testing expressions by compiling them against lists. Why it is wrong: SQL semantics (nulls, collation) differ. Do instead: integration tests against the real database.
What happens: expression-based frameworks for simple fixed queries. Why it is wrong: complexity and debugging cost. Do instead: use plain LINQ unless dynamic composition is needed.
Expression trees are the metaprogramming layer under LINQ providers, mocking, validation and mapping libraries. I use them directly for dynamic but safe filtering and for building typed accessors once, and I treat Compile() as an expensive operation to cache. In 2026-era .NET, source generators have replaced many runtime-compilation uses because they are faster at start-up and AOT-friendly, so I reach for expression trees mainly when the shape is truly dynamic, like user-defined filters.
IQueryable<T> is an IEnumerable<T> that also carries an expression tree and a provider. Queryable.Where takes an Expression<Func<T,bool>> and just wraps the expression, and when you enumerate, the provider, for example EF Core, translates the whole tree, typically into SQL. IEnumerable operators take delegates and run in memory. Which one you get depends on the static type of the variable, so storing a DbSet in an IEnumerable variable quietly moves every later Where into memory. AsEnumerable() does that on purpose. I keep IQueryable inside the data layer and return materialised results.
A lambda is an anonymous function, either an expression like x => x * 2 or a statement block. It has no type until it is converted, either to a delegate, which is compiled code, or to an Expression<Func<...>>, which is a data structure EF Core can turn into SQL. Since C# 10 a lambda can have a natural type, so var f = (int x) => x * 2 is a Func<int,int>. When it captures variables, the compiler hoists them into a closure class, which allocates. static lambdas forbid capture. Newer additions are attributes and return types (C# 10), default parameters (C# 12), and C# 14 parameter modifiers like out without writing types.
How to tell it (STAR)
**S:** A pen test found SQL injection in our dynamic report filters. **T:** Fix it without removing the feature. **A:** I replaced string-built SQL with whitelisted expression trees that EF Core translates to parameterised queries, and added tests for hostile input. **R:** The finding was closed in [timeframe], and names with apostrophes stopped breaking reports.
Last updated 30 Oct 2026.