Expression Trees and Func vs Expression

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!
Note: The same lambda syntax 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.
Tip: When you call .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.
Warning: EF Core cannot translate all C# methods to SQL. Calling a custom C# method inside a 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().

🧠 Test Yourself

Why does EF Core’s Where() accept Expression<Func<T, bool>> rather than just Func<T, bool>?