An expression tree is a data structure that represents C# code as a tree of objects โ not compiled, executable code, but a description of the code that can be inspected, modified, and translated into something else. EF Core uses expression trees to translate LINQ queries into SQL: when you write .Where(p => p.IsPublished) on a DbSet, EF Core receives an Expression<Func<Post, bool>>, inspects the tree, and generates WHERE IsPublished = 1. This is the mechanism that makes LINQ-to-SQL possible โ and understanding it prevents a class of subtle performance bugs.
Func vs Expression
// โโ Func<T, bool> โ compiled, executable code (runs in C#) โโโโโโโโโโโโโโโ
Func<Post, bool> func = p => p.IsPublished && p.ViewCount > 1000;
// This is a delegate: you can call it: func(somePost) โ true/false
// EF Core CANNOT inspect what's inside โ it receives a black-box function
// โโ Expression<Func<T, bool>> โ uncompiled code as data โโโโโโโโโโโโโโโโโ
Expression<Func<Post, bool>> expr = p => p.IsPublished && p.ViewCount > 1000;
// This is a data structure representing the lambda
// EF Core CAN inspect it: "parameter p, access IsPublished, AND, access ViewCount, GT, 1000"
// EF Core translates it to SQL: WHERE IsPublished = 1 AND ViewCount > 1000
// โโ The difference at the EF Core boundary โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
IQueryable<Post> query = _db.Posts;
// โ
Server-side: Where with Expression<> โ translated to SQL WHERE clause
var serverFiltered = query.Where(p => p.IsPublished); // SQL: WHERE IsPublished = 1
// โ Client-side: Where on IEnumerable โ downloads all rows, filters in C#!
IEnumerable<Post> asEnumerable = query.AsEnumerable();
var clientFiltered = asEnumerable.Where(p => p.IsPublished); // loads ALL posts first!
p => p.IsPublished compiles to different things depending on the target type. Assigned to Func<Post, bool>, it compiles to executable delegate code. Assigned to Expression<Func<Post, bool>>, it compiles to an expression tree object. EF Core’s IQueryable<T>.Where() method accepts Expression<Func<T, bool>> so it gets the tree. LINQ-to-Objects’ IEnumerable<T>.Where() accepts Func<T, bool> so it gets executable code. The entire IQueryable vs IEnumerable distinction is about this difference..AsEnumerable() or .ToList() on an EF Core query, you switch from IQueryable<T> to IEnumerable<T>. All subsequent LINQ operators run in C# against the in-memory data. This is sometimes intentional (when EF Core cannot translate a specific .NET method to SQL), but often accidental. Always identify where the IQueryable/IEnumerable boundary is in your repository methods and verify with EF Core query logging that the SQL is what you expect.Where predicate on an IQueryable causes EF Core 3+ to throw an InvalidOperationException (in older versions it silently switched to client evaluation, which loaded the entire table). If you need to use a non-translatable method, switch to client evaluation explicitly with AsEnumerable() after filtering as much as possible server-side โ never before.Inspecting Expression Trees
// Expression trees are inspectable objects
Expression<Func<Post, bool>> expr = p => p.IsPublished && p.ViewCount > 1000;
// The tree has nodes:
// BinaryExpression (AndAlso)
// Left: MemberExpression (p.IsPublished)
// Right: BinaryExpression (GreaterThan)
// Left: MemberExpression (p.ViewCount)
// Right: ConstantExpression (1000)
Console.WriteLine(expr.Body.NodeType); // AndAlso
Console.WriteLine(expr.Parameters[0].Name); // "p"
Console.WriteLine(expr.ToString());
// "p => (p.IsPublished AndAlso (p.ViewCount > 1000))"
// Compile to a Func (makes the expression executable)
Func<Post, bool> compiled = expr.Compile();
bool result = compiled(new Post { IsPublished = true, ViewCount = 2000 }); // true
The IQueryable vs IEnumerable Trap
// โโ The classic mistake โ calling AsEnumerable too early โโโโโโโโโโโโโโโโโโ
// โ Loads ALL published posts into memory, then paginates in C#
var badQuery = _db.Posts
.Where(p => p.IsPublished)
.AsEnumerable() // โ everything above runs in SQL, everything below in C#
.Skip(page * pageSize) // C# pagination โ unnecessary memory use
.Take(pageSize)
.ToList();
// โ
Entire operation runs as a single optimised SQL query
var goodQuery = await _db.Posts
.Where(p => p.IsPublished)
.OrderByDescending(p => p.CreatedAt)
.Skip(page * pageSize) // SQL OFFSET
.Take(pageSize) // SQL FETCH NEXT
.ToListAsync();
Common Mistakes
Mistake 1 โ Passing Func instead of Expression to a repository method
โ Wrong โ IEnumerable path: loads entire table, filters in C#:
public IEnumerable<Post> Find(Func<Post, bool> predicate)
=> _db.Posts.Where(predicate); // IEnumerable.Where โ client evaluation!
โ Correct โ Expression path: EF Core translates to SQL:
public IQueryable<Post> Find(Expression<Func<Post, bool>> predicate)
=> _db.Posts.Where(predicate); // IQueryable.Where โ server evaluation
Mistake 2 โ Calling AsEnumerable() before Skip/Take/Filter operations
โ Wrong โ loads the full table into memory before pagination.
โ
Correct โ keep the query as IQueryable until all server-side operations are applied, then call ToListAsync().