← Open in the full interactive course (progress tracking, search & more)

A lambda that compiles to a delegate is executable code. A lambda that compiles to an Expression<TDelegate> is a data structure you can read — and that one distinction is how EF Core turns C# into SQL.

Back in Intermediate lesson 145, you wrote something like context.Products.Where(p => p.Price > 100) against an EF Core DbSet<Product>, and it produced a SQL WHERE clause against a real database — not C# code running row-by-row in your process. Did you ever stop to ask how? p => p.Price > 100 is a lambda. Lambdas compile to IL. EF Core can't execute your process's IL against a SQL Server or PostgreSQL database — there's no way to "run" a compiled method on a database engine.

So EF Core doesn't run your lambda at all. It reads it — as data, inspecting its structure, and generates SQL from what it finds. The mechanism that makes a lambda readable as data, instead of only runnable as code, is an expression tree. It's a genuinely new idea, distinct from everything else in this module: up to now, every lambda you've written has become a delegate. This lesson is about the other thing a lambda can become.

By the end, you'll be able to build and inspect a small expression tree by hand, and you'll understand exactly what EF Core is doing every time you write a LINQ query against a database.

What Is It?

The Simple Explanation

An expression tree is a way of representing a small piece of C# code — usually a lambda — not as compiled, runnable instructions, but as a tree of ordinary .NET objects that describe the code's structure: "this is a comparison," "the left side is a property access," "the right side is a constant, 100." Because it's just a tree of objects, you can walk it, inspect it, print it, or even translate it into an entirely different language — like SQL.

The Technical Definition

An expression tree is built from types in the System.Linq.Expressions namespace, all ultimately deriving from the abstract base class System.Linq.Expressions.Expression. Common concrete node types include BinaryExpression (e.g. a > b), MethodCallExpression (e.g. obj.Method(args)), MemberExpression (e.g. p.Price), ConstantExpression (a literal value), and ParameterExpression (a lambda's parameter, like p). A lambda that gets turned into this kind of tree, as a whole, is represented by Expression<TDelegate> — a generic wrapper holding the tree's root, typed by the delegate signature the lambda matches.

The single most important fact in this entire lesson: whether a lambda becomes a delegate or an expression tree depends entirely on what type the compiler is targeting it against — nothing about the lambda's own syntax changes.

Func<int, bool> asDelegate   = x => x > 5;   // compiled to IL — a runnable delegate
Expression<Func<int, bool>> asTree = x => x > 5;   // compiled to DATA — an object graph describing "x > 5"

Identical lambda syntax on the right-hand side. Completely different compiler output, because the left-hand side's declared type is different.

Func<int, bool> — Code

Expression<Func<int, bool>> — Data

Why Does It Exist?

The Problem — A Delegate Can Only Be Run, Never Read

Once a lambda is compiled to IL and wrapped in a delegate, its "meaning" is effectively locked away. You can call it and observe its output, but there is no supported way to ask a Func<int, bool> instance "what comparison are you actually performing, and against what value?" That information existed in the compiler for a moment, at compile time — and then it's gone, flattened into opaque, runnable instructions.

This is a serious problem for any library that needs to translate C# logic into a completely different execution environment. EF Core's whole job is turning Where(p => p.Price > 100) into WHERE Price > 100 in SQL — but SQL Server has no idea what .NET IL is, and can't execute it. If Where only ever received a compiled delegate, EF Core would be stuck: its only option would be to pull every row back into memory and filter with C# — which is exactly the "load everything, then filter in memory" anti-pattern that makes database queries slow.

The Solution — Give the Library the Structure, Not Just the Behavior

Expression trees solve this by giving the compiler a second, entirely different way to represent the exact same lambda syntax: instead of "compile this down to something runnable," the compiler is told "build me an object graph describing this code's shape." A library like EF Core's LINQ provider receives that object graph, walks it node by node — "this is a member access on Price," "this is a greater-than comparison," "this is the constant 100" — and generates the equivalent SQL text itself. Your C# lambda was never executed by the CLR at all; it was only ever read.

The key insight

Every LINQ provider that talks to something other than in-memory .NET objects — EF Core talking to SQL, or any other LINQ-to-anything provider — depends on expression trees. LINQ-to-Objects (IEnumerable<T>'s Where/Select) uses plain delegates and actually runs your lambda in-process; LINQ-to-Entities (IQueryable<T>'s Where/Select) uses expression trees and translates your lambda into another language entirely. Same-looking LINQ syntax, two fundamentally different mechanisms underneath — and the type of the sequence you're querying (IEnumerable<T> vs. IQueryable<T>) is what silently picks which one you get.

Big Picture

HOW p => p.Price > 100 BECOMES SQL
p => p.Price > 100   (your C# lambda)
Target type is Expression<Func<Product,bool>> → compiler builds a TREE, not IL
EF Core's LINQ provider receives the tree and walks its nodes
Provider recognizes: MemberExpression("Price") > ConstantExpression(100)
Provider emits: WHERE [Price] > 100
SQL runs on the DATABASE — your lambda's IL is never executed at all

How It Works

BUILDING AND READING A TREE, STEP BY STEP
1. DECLARE THE TARGET TYPE AS Expression<TDelegate>
Expression<Func<int, bool>> expr = x => x > 5;
2. INSPECT THE TREE'S PIECES
ParameterExpression param = expr.Parameters[0]; // "x"
BinaryExpression body = (BinaryExpression)expr.Body; // "x > 5"
Console.WriteLine(body.NodeType);  // GreaterThan
Console.WriteLine(body.Left);      // x
Console.WriteLine(body.Right);     // 5
3. COMPILE THE TREE BACK INTO A RUNNABLE DELEGATE, ON DEMAND
Func<int, bool> compiled = expr.Compile();
Console.WriteLine(compiled(10)); // True — NOW it's actually running as code
4. A LINQ PROVIDER SKIPS STEP 3 ENTIRELY — IT TRANSLATES INSTEAD

Simple Example

using System;
using System.Linq.Expressions;

class Program
{
    static void Main()
    {
        // Target type is Expression>, NOT Func —
        // this line does not produce a runnable delegate at all.
        Expression<Func<int, bool>> expr = x => x > 5;

        // Inspect the tree — reading the code as data
        Console.WriteLine($"Parameter: {expr.Parameters[0].Name}");      // x
        Console.WriteLine($"Body: {expr.Body}");                          // (x > 5)

        var comparison = (BinaryExpression)expr.Body;
        Console.WriteLine($"Operator: {comparison.NodeType}");           // GreaterThan
        Console.WriteLine($"Left side: {comparison.Left}");              // x
        Console.WriteLine($"Right side: {comparison.Right}");            // 5

        // Turn the tree BACK into runnable code, only when we actually need to execute it
        Func<int, bool> isGreaterThanFive = expr.Compile();
        Console.WriteLine(isGreaterThanFive(10)); // True
        Console.WriteLine(isGreaterThanFive(2));  // False
    }
}

Code → Meaning → Result: Nothing about x > 5 ever ran as compiled C# until the explicit call to expr.Compile(). Before that, expr was purely a description — an object graph you could print, take apart, and reason about, the same way you'd inspect any other data structure. That's the entire idea, distilled to its smallest possible example.

Real-World Example

Recall the EF Core query from Intermediate lesson 145:

var expensiveProducts = context.Products
    .Where(p => p.Price > 100)
    .ToList();

You now have the vocabulary to explain exactly what happens here. context.Products is an IQueryable<Product> — and IQueryable<T>'s Where extension method has a signature that expects an expression tree, not a delegate:

// The IQueryable version of Where (simplified):
public static IQueryable<T> Where<T>(
    this IQueryable<T> source,
    Expression<Func<T, bool>> predicate); // an EXPRESSION TREE, not a Func

// Compare to the IEnumerable version (LINQ-to-Objects), which you already know:
public static IEnumerable<T> Where<T>(
    this IEnumerable<T> source,
    Func<T, bool> predicate); // an ordinary DELEGATE

Because p => p.Price > 100 is being passed to the Expression<Func<T,bool>> overload, the compiler builds a tree — a MemberExpression for p.Price, a ConstantExpression for 100, wrapped in a BinaryExpression for >. EF Core's query provider walks that exact tree, recognizes each node, and produces:

SELECT [p].[Id], [p].[Name], [p].[Price]
FROM [Products] AS [p]
WHERE [p].[Price] > 100

Your lambda's C# was never executed by your application — it was read once, as data, and translated. This is also exactly why calling an arbitrary C# method (something the provider doesn't recognize how to translate) inside an EF Core Where lambda often throws at query time: the provider walked the tree, hit a node it doesn't know how to turn into SQL, and had nothing left to do but fail. It's not "EF Core can't run your code" — it deliberately never tries to.

Analogy

A Recipe vs. a Cooked Meal

A delegate is a cooked meal — ready to consume, but you can't easily reconstruct the exact recipe just by tasting it. An expression tree is the written recipe: "take 2 cups of flour, add 1 egg, mix." You can't eat a recipe directly, but you can read it, hand it to someone else, or even have a completely different kitchen — one that's never seen your original ingredients — cook an equivalent meal from the same instructions.

That's exactly EF Core's job: it's a different kitchen (SQL Server) that can't run your C# "cooking equipment" (the CLR) at all. Handing it the recipe (the expression tree) instead of the cooked meal (a compiled delegate) is the only way it can produce an equivalent result in its own kitchen.

Under the Hood

WHAT THE COMPILER ACTUALLY EMITS FOR AN EXPRESSION TREE
1. THE TREE IS BUILT WITH ORDINARY .NET FACTORY METHOD CALLS

Behind the scenes, Expression<Func<int,bool>> expr = x => x > 5; compiles to something conceptually equivalent to this hand-written construction — the compiler is essentially calling Expression's own static factory methods for you:

ParameterExpression x = Expression.Parameter(typeof(int), "x");
ConstantExpression five = Expression.Constant(5);
BinaryExpression comparison = Expression.GreaterThan(x, five);
Expression<Func<int, bool>> expr =
    Expression.Lambda<Func<int, bool>>(comparison, x);
2. Expression<TDelegate>.Compile() IS THE BRIDGE BACK TO EXECUTABLE CODE
3. NOT EVERY LAMBDA CAN BE AN EXPRESSION TREE

Common Confusion

1. "Expression<Func<T,bool>> IS a Func<T,bool>" — it isn't, until you compile it

These are two distinct, unrelated types. Expression<Func<int,bool>> cannot be invoked with expr(5) — there is no Invoke on it in the way there is on a delegate. You must explicitly call .Compile() to get back an actual Func<int,bool> before you can call it like a method.

2. "LINQ always translates to SQL" — only IQueryable<T> LINQ does

Where/Select/etc. on an in-memory List<T> or array use the IEnumerable<T> extension methods, which take plain Func delegates and actually execute your C# in-process, exactly like every LINQ example from Intermediate. Only when you're querying an IQueryable<T> source — like an EF Core DbSet<T> — does the expression-tree-based overload kick in and translation happen instead.

3. "Expression trees are a LINQ-only feature" — they're a general-purpose mechanism LINQ happens to use heavily

The System.Linq.Expressions namespace predates and exists independently of any specific LINQ provider. Anything that needs to inspect, generate, cache, or dynamically build C#-shaped logic at runtime — some serialization libraries, mocking frameworks, and rules engines — can and does use expression trees directly, with no LINQ query in sight.

Common Mistakes

Mistake 1 — Calling a C# method EF Core can't translate, inside a query

context.Products.Where(p => SomeComplexCSharpHelper(p)) — if EF Core's provider doesn't recognize SomeComplexCSharpHelper as something it can turn into SQL, the query throws (or in older EF versions, silently evaluated client-side, which quietly fetches far more data than intended).

Keep the logic inside a query expression tree to what the provider can translate — simple property access, comparisons, and the handful of methods (like string.Contains) EF Core specifically knows how to map to SQL. Do custom C#-only logic after materializing results with .ToList(), once you're back to working with plain objects in memory.

Mistake 2 — Calling .Compile() repeatedly on the same expression tree in a hot path

Rebuilding the same Expression<Func<...>> and calling .Compile() on it inside a loop or on every request — each call regenerates IL from scratch, which is meaningfully slower than a single cached delegate call.

Compile once, cache the resulting delegate (a static readonly field, for example), and reuse it — the same instinct as caching any other expensive-to-construct object.

Mistake 3 — Assuming every lambda syntax works as an expression tree

Trying to assign a lambda with a multi-statement block body — Expression<Func<int,int>> f = x => { var y = x + 1; return y * 2; }; — and being surprised it doesn't compile.

Expression trees are restricted to single-expression lambda bodies. If you need multi-statement logic, you need a plain delegate (Func/Action), not an expression tree — the two aren't always interchangeable just because the syntax looks similar.

When Should I Use It?

The one-sentence version: Func<T,TResult> asks the compiler "make this runnable." Expression<Func<T,TResult>> asks it "make this readable." Everything else in this lesson follows from that single difference.

Mental Model

Target type Func<T,TResult> → the compiler builds code (a delegate).
Target type Expression<Func<T,TResult>> → the compiler builds data (a tree of Expression objects).
.Compile() turns the data back into code, on demand.

EF Core reads the data and writes SQL from it — your lambda's C# code is never executed.

Key Takeaway


Check Your Understanding

You've seen how a lambda becomes inspectable data instead of runnable code, and exactly how EF Core relies on that. Let's confirm it's solid.

1. What determines whether x => x > 5 is compiled to a delegate or an expression tree?

Show answer

Correct: B

Why B is correct: The identical lambda syntax compiles completely differently depending purely on the declared type it's targeting — Func<T,TResult> for a delegate, Expression<Func<T,TResult>> for a tree. Nothing about the lambda's own text changes.

Why A is incorrect: Both expression-bodied and block-bodied lambda syntax can target a delegate type; only the single-expression form can target Expression<TDelegate> at all, but the arrow syntax itself doesn't decide which one happens.

Why C is incorrect: A standalone Expression<Func<...>> variable, with no LINQ query anywhere nearby, behaves identically to one inside a query — it's the target type, not the surrounding context, that matters.

Why D is incorrect: Expression trees have been supported since C# 3.0 and are unrelated to which modern language version is in use.

Reinforcement: Target type is everything — this is the single rule the rest of the lesson builds on.

2. Why can't EF Core simply receive your Where lambda as a compiled Func<Product,bool> delegate and use that to query a SQL database?

Show answer

Correct: B

Why B is correct: A compiled delegate can only be invoked — its internal structure ("this is a greater-than comparison against a constant") isn't recoverable at runtime. EF Core needs that structure to generate equivalent SQL, which is exactly what an expression tree preserves and a delegate doesn't.

Why A is incorrect: Delegate invocation speed isn't the issue at all — the fundamental problem is that a database engine can't execute .NET IL in the first place, regardless of speed.

Why C is incorrect: EF Core supports and uses Func-shaped signatures constantly — the point is that its IQueryable<T> methods specifically request Expression<Func<...>>, not a bare Func.

Why D is incorrect: SQL Server has no mechanism to execute .NET IL or CLR delegates — this is precisely the gap expression trees exist to bridge.

Reinforcement: The database can't run your code — only a description of your code's logic can cross that boundary, and that description is the expression tree.

3. Given Expression<Func<int, bool>> expr = x => x > 5;, what does expr.Compile() return, and what happens to expr itself?

Show answer

Correct: A

Why A is correct: Compile() reads the tree and emits new IL, handing back a fresh, independent Func<int, bool> delegate. The original expr object is untouched and still fully inspectable afterward — the two are separate, coexisting representations.

Why B is incorrect: Expression tree objects aren't mutated by compiling them — Compile() produces a brand-new, separate delegate object.

Why C is incorrect: Compile() only builds and returns the delegate; you still have to call the returned delegate yourself (e.g. compiled(10)) to actually execute it.

Why D is incorrect: Any Expression<TDelegate> whose body is a valid, compilable expression can call .Compile() — nothing requires it to have originated from a LINQ query specifically, as this lesson's own hand-built example shows.

Reinforcement: Compiling a tree produces a new delegate object; it doesn't consume or alter the tree.

4. You write a LINQ query against an in-memory List<Product>: products.Where(p => p.Price > 100). Does this build an expression tree?

Show answer

Correct: B

Why B is correct: List<Product> is an IEnumerable<Product>, not an IQueryable<Product>, so the compiler resolves Where to the IEnumerable<T> extension method, which is declared to take a Func<T,bool> — an ordinary delegate that runs your C# directly, in-process.

Why A is incorrect: Only the IQueryable<T> overloads of LINQ methods take expression trees; the far more common IEnumerable<T> overloads (used for in-memory collections) take plain delegates.

Why C is incorrect: Whether EF Core is installed is irrelevant here — the deciding factor is the compile-time type of products (List<Product>/IEnumerable<Product>), which never changes based on installed packages.

Why D is incorrect: The specific operators used inside the lambda have no bearing on which Where overload is selected — that's determined entirely by the source collection's type.

Reinforcement: IEnumerable<T> LINQ runs your code; IQueryable<T> LINQ reads your code as a tree — the source type silently decides which one you're using.

You now know exactly what happens between writing p => p.Price > 100 and seeing a WHERE clause hit your database. Next: the compiler's other lambda trick — how it turns a capturing lambda into a hidden class, and when a lambda allocates at all.


dotnetmadeeasy.com — Learn C# and .NET, the right way.