Back to Blog
C#

C# Expression Tree Internals: How Code Becomes Data

c# expression tree internals: Explore how C# expression trees represent code as data, how Compile() produces delegates, and when to use them in production.

expression treesLINQExpressionVisitorreflectiondynamic code
Illustration of a C# expression tree with nodes branching into a compiled delegate arrow

c# expression tree internals requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

An expression tree in C# is a runtime representation of a piece of executable code. Instead of compiling a lambda like x => x.Age > 18 into a delegate directly, the compiler can build a tree of objects that describe the same logic: a node for the parameter x, a node for the property access x.Age, a constant node for 18, and a node for the > comparison. That tree can be inspected, rewritten, and finally turned back into executable code with Compile().

This is the mechanism behind LINQ-to-SQL and EF Core query translation. The provider receives an expression tree rather than a delegate, walks it, and produces SQL. The same machinery is available to any library that needs to interpret or transform code at runtime.

The phrase "c# expression tree internals" usually refers to the node hierarchy, the compilation path, and the visitor pattern that makes tree rewriting practical. All three are needed to use expression trees beyond trivial examples.

The Node Hierarchy and the Expression Base Class

Every node in an expression tree derives from the abstract Expression class. The concrete node types cover the shapes of C# code: ConstantExpression for literals, ParameterExpression for parameters, BinaryExpression for operators, MethodCallExpression for method invocations, MemberExpression for field or property access, and LambdaExpression for the whole lambda. The ExpressionType enum classifies each node.

Expression<Func<Person, bool>> predicate = p => p.Age > 18;

The compiler produces a tree whose root is a LambdaExpression. Walking down from the root:

  • LambdaExpression.Body is the BinaryExpression for >.
  • BinaryExpression.Left is a MemberExpression for p.Age.
  • MemberExpression.Expression is the ParameterExpression for p.
  • BinaryExpression.Right is a ConstantExpression holding 18.

Each node exposes only the properties relevant to its kind. A BinaryExpression has Left and Right; a MethodCallExpression has Object, Method, and Arguments; a ConstantExpression has Value. The base Expression class provides NodeType and Type, where Type is the CLR type the node evaluates to.

This design is what makes generic traversal possible. A visitor can switch on NodeType or use the strongly typed Visit overloads without knowing the full shape of the tree in advance.

How Compile() Turns a Tree into a Delegate

The Compile() method on LambdaExpression produces a delegate that executes the logic described by the tree. Internally, the expression compiler walks the tree and emits IL instructions for each node. A ConstantExpression becomes a load instruction, a BinaryExpression becomes the corresponding arithmetic or comparison opcode, and a MemberExpression becomes a field or property access.

The resulting delegate is a regular delegate. Once compiled, it no longer references the tree; the tree can be discarded if it is not needed again. That means the cost of compilation is paid once, at the point where Compile() is called.

Expression<Func<int, int>> square = x => x * x; Func<int, int> compiled = square.Compile(); int result = compiled(5); // 25

The compiled delegate carries no expression-tree metadata. If you need both behaviors — inspection and execution — you must keep the tree and the compiled delegate separately.

A subtlety: Compile() on an untyped LambdaExpression returns a Delegate, so you must cast it or use DynamicInvoke. The typed Expression<TDelegate> class exists to avoid that friction. Its Compile() returns a TDelegate directly.

Traversing and Rewriting with ExpressionVisitor

The ExpressionVisitor class is the standard tool for walking a tree and producing a new one. Its Visit method dispatches to a Visit* method for each node type. The default implementation returns the node unchanged. Overriding one of the Visit* methods lets you inspect or replace specific kinds of nodes.

sealed class ParameterReplacer : ExpressionVisitor { private readonly ParameterExpression _replacement; public ParameterReplacer(ParameterExpression replacement) { _replacement = replacement; } protected override Expression VisitParameter(ParameterExpression node) { return _replacement; } }

The visitor pattern matters because expression trees are immutable. You cannot mutate a node's Left or Right; you can only build a new tree. The visitor does this for you: when a child changes, it constructs a new parent node with the updated child.

This is how a query provider rewrites an expression before translating it. For example, replacing a parameter with a constant, or replacing a method call with a different call, is a small visitor override.

A Practical Example: Building a Dynamic Filter

A common production use is building a predicate when the field name is only known at runtime. Reflection can do this, but expression trees produce a strongly typed delegate with less overhead per invocation after compilation.

public static Func<T, bool> BuildPredicate<T>(string propertyName, object value) { var parameter = Expression.Parameter(typeof(T), "item"); var property = Expression.Property(parameter, propertyName); var constant = Expression.Constant(value); var body = Expression.Equal(property, constant); var lambda = Expression.Lambda<Func<T, bool>>(body, parameter); return lambda.Compile(); }

The key detail is that Expression.Property resolves the property through reflection and produces a MemberExpression. If the property does not exist, it throws at build time, not at invocation time. That is usually the desired behavior: fail fast when the field name is wrong.

This approach is appropriate when the predicate is built once and reused many times, such as a filter applied to an in-memory collection on each request. If the predicate were rebuilt for every item, the compilation cost would dominate.

Performance and Allocation Considerations

The main runtime cost in expression trees is Compile(). Building the tree itself is cheap relative to compilation, but it still allocates node objects. Compiling the same shape of expression repeatedly — for example, inside a hot loop — wastes CPU and increases GC pressure.

Caching the compiled delegate is the standard mitigation. A ConcurrentDictionary keyed by the inputs that determine the tree shape works well.

private static readonly ConcurrentDictionary<string, Delegate> Cache = new(); public static Func<T, bool> GetPredicate<T>(string propertyName, object value) { string key = $"{typeof(T).FullName}.{propertyName}.{value}"; return (Func<T, bool>)Cache.GetOrAdd(key, _ => BuildPredicate<T>(propertyName, value)); }

The cache key must include every input that changes the tree. If the value type varies, the key must reflect that, because Expression.Constant stores the value with its runtime type.

Another consideration is that expression trees capture variables by reference. A lambda that closes over a local variable produces a closure object, and the tree references that closure through a ConstantExpression. If the closure is mutable, the compiled delegate observes changes to the variable. This is correct C# semantics, but it can surprise developers who expect the tree to snapshot the value at build time.

Common Pitfalls: Quoting, Immutability, and Type Mismatches

Three issues account for most expression-tree bugs in production code.

First, nested lambdas. If you build a tree that contains a lambda inside another lambda, the inner lambda must be wrapped with Expression.Quote. Without quoting, the runtime treats the inner lambda as a delegate creation, not as an expression. The Expression.Lambda factory handles quoting automatically when the argument type is an Expression; manual construction does not.

Second, immutability. Because nodes are immutable, any transformation must rebuild the path from the changed node to the root. A visitor handles this, but a hand-written recursive rewrite must do the same. Forgetting to rebuild a parent node produces a tree that silently ignores the change.

Third, type mismatches in Expression.Constant. The constant's type must match the node's expected type. Comparing an int property to a boxed long constant produces an invalid tree that fails at Compile() time or at runtime. Normalizing the value type before building the tree avoids this.

object normalized = Convert.ChangeType(value, property.PropertyType); var constant = Expression.Constant(normalized, property.PropertyType);

This is a small detail, but it is the difference between a predicate that works for one caller and one that fails for another.

When Expression Trees Are the Right Tool

Expression trees are not the only way to handle dynamic code. Reflection can read and invoke members without any compilation step, and source generators can produce strongly typed code at build time. The choice depends on the shape of the problem.

Use expression trees when you need to inspect or transform code as data, or when you need a compiled delegate that is invoked many times with the same shape. A query provider translating to SQL is the canonical case. A dynamic filter over an in-memory collection is a smaller but valid case.

Use reflection when the operation happens once and the invocation cost is irrelevant. Use a source generator when the dynamic behavior is known at compile time and you want zero runtime compilation.

The boundary is the same one that governs most runtime metaprogramming: the cost of building and compiling must be amortized over enough invocations, and the dynamic behavior must genuinely require runtime input. When both conditions hold, expression trees are the most direct tool in the C# runtime.

c# expression tree internals: Practical Usage and Code Examp | RYUSLOG DEV