📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are expression trees in C#? Explain how the compiler turns a lambda into an Expression<TDelegate>, how to build, inspect and compile expression trees at run time, how LINQ providers and libraries use them, what C# features cannot appear in them, and the performance and AOT costs of Compile().

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.

Senior Coding Expert csharp ef-core expression-trees linq metaprogramming

🎯 Key points

  • An expression tree represents code as data: `Expression<Func<T,bool>>` holds nodes (Parameter, MemberAccess, Binary, Constant...) instead of IL
  • The compiler builds the tree when a lambda is assigned to an `Expression<TDelegate>`; only expression-bodied lambdas qualify
  • Providers (EF Core, OData) visit the tree to translate it; libraries use it for strongly typed member names, mapping and validation
  • Build trees with `Expression.Parameter`, `Property`, `Lambda` and friends; modify them with `ExpressionVisitor`
  • Combining predicates needs parameter replacement, not Expression.Invoke, for providers to translate reliably
  • `Compile()` generates IL via dynamic methods: costly (micro- to milliseconds), so cache compiled delegates
  • Under Native AOT there is no IL emit; Compile falls back to an interpreter, which is slower
  • Not allowed in expression trees: statement bodies, assignments, null-conditional `?.`, tuple literals, switch expressions, async/await, ref locals

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Lambda → 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).

2. Building, inspecting and compiling at run time

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

3. How LINQ providers and libraries use them

  • EF Core / LINQ providers: translate trees into SQL (or Cosmos queries, OData URLs, MongoDB queries).
  • Strongly typed member references: 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.
  • Fast reflection: building and compiling expressions that create objects or get/set properties, cached as delegates (much faster than PropertyInfo.GetValue per call; modern .NET's reflection is faster, and source generators are better still).
  • Specifications and dynamic filters: composing queries from reusable expressions.

4. C# features that can't appear in expression trees

The compiler rejects (CS8xxx errors) or doesn't support in expression lambdas:

  • Statement bodies (blocks, loops, if statements, local variable declarations) — only expression lambdas.
  • Assignment operators (=, +=), ++/--.
  • async/await, yield.
  • Null-propagating ?. (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.

5. Performance and AOT costs of 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.
  • Compiled delegates may be slightly slower than normal lambdas (closure handling).
  • Under NativeAOT (no JIT), 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.
  • Building trees also allocates many nodes; EF Core caches translations by tree shape to avoid repeated work.

6. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Node types

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.

2. Combining predicates correctly

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.

3. How closures look in trees

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.

4. Expression trees and the query cache

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.

5. Alternatives

  • Source generators: compile-time code instead of runtime expression compilation (AOT-friendly).
  • Reflection with MethodInvoker/UnsafeAccessor (.NET 8) for fast member access.
  • Function pointers/delegates created once.
  • Dynamic LINQ libraries (System.Linq.Dynamic.Core) parse strings into expression trees — convenient for admin grids but need careful allow-listing.

Try it yourself

  1. Print Expression<Func<Order,bool>> trees with .ToString() and walk them with a custom ExpressionVisitor.
  2. Build a dynamic equality filter and use it with EF Core; check ToQueryString().
  3. Combine two predicates with the parameter-replacing visitor.
  4. Benchmark Compile() once vs per call, and test under NativeAOT to see interpretation.

💻 Code

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
    }
}

⚠ Common mistakes

1. Compiling expressions per call

What happens: expr.Compile() inside a request or loop. Why it is wrong: expensive code generation each time. Do instead: cache compiled delegates.

2. Building trees from unvalidated user input

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.

3. Embedding values as constants

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.

4. Combining with Expression.Invoke

What happens: predicates combined via Invoke. Why it is wrong: some providers can't translate it. Do instead: rebind parameters with a visitor.

5. Using unsupported C# features

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

6. Relying on Compile() in NativeAOT apps

What 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.

7. Assuming in-memory compiled behaviour equals SQL behaviour

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.

8. Over-engineering with expression trees

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions EF Core translating trees. Why: Expression trees are what IQueryable providers consume.
    How to answer

    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.

  2. When: Candidate says statement lambdas cannot be expression trees. Why: Only expression lambdas convert to trees; tests lambda knowledge.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Dynamic report filters replaced string-built SQL
Situation
A reporting API let users add filters in the UI (field, operator, value). The legacy implementation concatenated them into SQL strings.
Signals
A penetration test flagged SQL injection in the filter endpoint; filters also broke on values containing apostrophes, such as Irish surnames.
Investigation
Reviewed the query builder and confirmed user input reached SQL text; listed the fields and operators the UI actually needed.
Resolution
Built expression trees from a whitelist of fields and operators, converted values to the property type, combined them with a parameter-replacing visitor, and passed them to EF Core's Where, which produced parameterised SQL.
Prevention
Added unit tests for every operator and malicious input, an integration test that inspects ToQueryString for parameters, and a security review checklist item.

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.

↩ Usually asked after

More in LINQ

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me