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

Lesson 188 taught you how to build a tree and compile it. This lesson teaches you how something on the other end actually reads one.

Lesson 188 ended with a promise: EF Core "walks the tree" and "recognizes each node." That was left as a black box on purpose — you needed IQueryable<T> (lesson 197) first, to understand what gets handed to a provider and when. Now you have both pieces. This lesson opens the black box: not by reproducing EF Core's actual internals, which are large, proprietary, and far more complex than any single lesson could responsibly cover — but by building a genuinely working, fully-explained visitor of your own, small enough to read top to bottom, that does the same category of work at a much smaller scale.

You'll meet ExpressionVisitor — the base class .NET itself provides for exactly this job — and its overridable Visit, VisitBinary, VisitConstant, and VisitMethodCall methods. Then you'll build two small, complete visitors: one that collects every constant value hidden inside a tree, and one that turns a tree back into a readable string — a miniature, honest version of what a real LINQ provider does when it turns your Where lambda into a SQL WHERE clause.

What Is It?

The Simple Explanation

An expression tree, once built, is just a graph of objects sitting in memory — a BinaryExpression with a Left and a Right, each of which might itself be another node with its own children, all the way down to leaves like ConstantExpression or ParameterExpression. "Walking" the tree means visiting every node, recursively, and doing something with each one you recognize. ExpressionVisitor is the piece of the BCL that provides the recursive-walking part for free, so you only have to write the part where you actually react to specific node types.

The Technical Definition

System.Linq.Expressions.ExpressionVisitor is an abstract-ish base class (its members are all virtual, with default implementations, so you don't have to override every single one) with one method per Expression node type, plus a general entry point:

public class ExpressionVisitor { public virtual Expression Visit(Expression node); // The general entry point — inspects node's runtime type and // dispatches to the matching specific method below. protected virtual Expression VisitBinary(BinaryExpression node); // x > 5, a && b, ... protected virtual Expression VisitConstant(ConstantExpression node); // 5, "hello", true protected virtual Expression VisitParameter(ParameterExpression node);// x, p, the lambda's own parameter protected virtual Expression VisitMember(MemberExpression node); // p.Price, p.Name protected virtual Expression VisitMethodCall(MethodCallExpression node); // Where(...), Contains(...) protected virtual Expression VisitLambda<T>(Expression<T> node); // the whole x => x > 5 protected virtual Expression VisitUnary(UnaryExpression node); // -x, (int)x, !flag // ...and around a dozen more, one per Expression subtype }

Every one of these default implementations does exactly one thing: recursively call Visit on each of the node's own children, and rebuild an equivalent node from whatever comes back. Override just the ones you care about — say, VisitConstant — and the rest of the tree gets walked correctly for free, because the base class's default behavior for every node you didn't override is simply "visit my children too."

Why Does It Exist?

The Problem — Recursive Tree-Walking Code Is Repetitive and Easy to Get Wrong

Without ExpressionVisitor, writing something that needs to inspect every constant hidden anywhere in a tree means writing a big switch (or a chain of is checks) over every single Expression subtype yourself — BinaryExpression has a Left and Right to recurse into, MethodCallExpression has an Object and an Arguments list, UnaryExpression has an Operand, and so on for roughly twenty different node shapes. Forgetting to recurse into just one of them means silently missing part of the tree — a real, easy class of bug.

The Solution — a Base Class That Already Knows How to Recurse Correctly

ExpressionVisitor has already encoded, correctly, exactly which children each of the ~20 Expression subtypes has and how to recurse into every one of them. Your job shrinks to overriding only the specific node types your visitor actually cares about — everything else is walked correctly by the inherited default behavior, with zero risk of accidentally skipping a branch of the tree you didn't think to handle.

Big Picture

THE SAME TREE, WALKED FOR DIFFERENT PURPOSES
One expression tree — p => p.Price > 100 && p.InStock — the same shape, three very different visitors:
This lesson's ConstantCollector
finds 100, true
This lesson's PrettyPrinter
produces (p.Price > 100) AND p.InStock
EF Core's real provider
produces WHERE [p].[Price] > 100 AND [p].[InStock] = 1
Same tree, same recursive-walk technique — a different reaction to each node type is the only thing that changes.

How It Works

BUILDING A CUSTOM VISITOR, STEP BY STEP
1. DERIVE FROM ExpressionVisitor
2. OVERRIDE ONLY THE NODE TYPES YOU CARE ABOUT
3. INSIDE AN OVERRIDE, DO YOUR WORK, THEN KEEP THE RECURSION GOING
4. CALL Visit() ONCE, ON THE ROOT — THE RECURSION HANDLES THE REST

Simple Example

The smallest genuinely useful visitor: collect every constant value anywhere inside a tree. Shown in full, every line explained:

using System.Linq.Expressions; public class ConstantCollector : ExpressionVisitor { public List<object?> Constants { get; } = []; // Override JUST this one method — every other node type // keeps using ExpressionVisitor's own default (recurse into children). protected override Expression VisitConstant(ConstantExpression node) { Constants.Add(node.Value); // do OUR work: record the value return base.VisitConstant(node); // then let the base class finish its job for this node } } // ─── Usage ─── Expression<Func<int, bool>> expr = x => x > 5 && x < 100; var collector = new ConstantCollector(); collector.Visit(expr); // ONE call, on the root — recursion does everything else foreach (var value in collector.Constants) Console.WriteLine(value); // 5 // 100

Code → Meaning → Result: collector.Visit(expr) starts at the lambda itself, which ExpressionVisitor's built-in VisitLambda recurses into — down to the && (a BinaryExpression), which its built-in VisitBinary recurses into both sides of — down to x > 5 and x < 100, each themselves a BinaryExpression, each with a ConstantExpression child (5 and 100). VisitConstant is the only method this visitor overrode — but because it's called once for every constant anywhere in that whole nested structure, both values get collected, without ConstantCollector ever having to know or care that &&, >, and < even exist as distinct node types.

A Second Visitor — a Minimal Pretty-Printer

A slightly larger, still fully-explained example: turn a tree back into a readable string, by overriding two more node types.

using System.Linq.Expressions; using System.Text; public class PrettyPrinter : ExpressionVisitor { private readonly StringBuilder _sb = new(); public string Result => _sb.ToString(); protected override Expression VisitBinary(BinaryExpression node) { _sb.Append('('); Visit(node.Left); // recurse into the LEFT side ourselves _sb.Append(node.NodeType switch { ExpressionType.GreaterThan => " > ", ExpressionType.LessThan => " < ", ExpressionType.AndAlso => " AND ", ExpressionType.Equal => " == ", _ => $" {node.NodeType} " }); Visit(node.Right); // then the RIGHT side _sb.Append(')'); return node; // we already recursed manually — no base.VisitBinary(node) needed here } protected override Expression VisitConstant(ConstantExpression node) { _sb.Append(node.Value); return node; } protected override Expression VisitMember(MemberExpression node) { // For something like "p.Price", node.Member.Name is "Price" _sb.Append(node.Member.Name); return node; } } // ─── Usage ─── Expression<Func<int, bool>> simple = x => x > 5; var printer = new PrettyPrinter(); printer.Visit(simple.Body); // start at the BODY — we don't care about printing the parameter list Console.WriteLine(printer.Result); // (x > 5)

Notice the difference from ConstantCollector: VisitBinary here does not call base.VisitBinary(node) at the end. That's deliberate and correct — because this override manually calls Visit(node.Left) and Visit(node.Right) itself (in order, with the operator text placed correctly between them), it has already fully handled the recursion for this node. Calling base.VisitBinary afterward would walk the same children a second time, uselessly. This is the one genuinely tricky rule in writing an ExpressionVisitor: decide, per override, whether you're doing extra work around the default recursion (call base.VisitX) or replacing the recursion entirely because you need to control the order or formatting yourself (recurse manually, skip the base call).

Real-World Example

Extend PrettyPrinter with VisitMethodCall, and you're now recognizing the exact node shape that a Where(...) call produces — conceptually the same first step any real LINQ provider takes:

protected override Expression VisitMethodCall(MethodCallExpression node) { if (node.Method.Name == "Where") { _sb.Append("SELECT * WHERE "); // node.Arguments[1] is the predicate lambda passed to Where(...) — see lesson 197's // "Under the Hood" for exactly why it arrives wrapped as an Expression.Quote node. var predicateBody = ((LambdaExpression)((UnaryExpression)node.Arguments[1]).Operand).Body; Visit(predicateBody); return node; } return base.VisitMethodCall(node); // any OTHER method call — let the base class keep recursing } // ─── Given the tree behind: someQueryable.Where(p => p.Price > 100) ─── // printer.Visit(...) on that MethodCallExpression now produces: // SELECT * WHERE (Price > 100)

This is deliberately tiny compared to what EF Core's real provider does — it recognizes exactly one method name, produces a fixed string shape, and has no concept of table names, parameterization, SQL dialects, or the dozens of LINQ operators (OrderBy, GroupBy, Select, Join, and more) a production provider must handle correctly, safely, and efficiently. But the technique — walk the tree, recognize a MethodCallExpression node by name, recognize a BinaryExpression node by its NodeType, and emit an equivalent fragment for each recognized shape — is genuinely the same category of work EF Core's provider performs, just at a scale and level of correctness this lesson makes no claim to replicate. That's the entire, honest connection this lesson is drawing: not "here's how EF Core actually works internally," but "here's the same kind of tool, small enough to fully understand, that the same kind of problem calls for."

Analogy

A Tour Guide Who Only Speaks Up at Certain Landmarks

Imagine a walking tour that automatically visits every room of a large building, in the correct order, without you needing to plan the route at all — that's what ExpressionVisitor's built-in recursion gives you for free. Your job is only to hand the tour guide a list of instructions like "whenever you enter a room with a fountain, write down what's written on the fountain's plaque" (that's VisitConstant) or "whenever you enter a room shaped like a fork in the hallway, announce which direction you're taking and why" (that's VisitBinary). You never plan the route yourself — you only decide what to notice, and the guide keeps walking through every room regardless, unless you explicitly tell it to stop at one and take over.

Under the Hood

HOW Visit() ACTUALLY DISPATCHES
1. Visit(Expression) CHECKS THE NODE'S NodeType, THEN CALLS THE MATCHING PROTECTED METHOD
2. NODES ARE IMMUTABLE — "REWRITING" A TREE MEANS BUILDING A NEW ONE
3. WHAT A REAL PROVIDER ADDS ON TOP OF THIS TECHNIQUE — AND WHY THIS LESSON STOPS HERE

Common Confusion

1. "Overriding a VisitX method automatically stops the recursion" — no, forgetting base does

The single most common bug when first writing an ExpressionVisitor: overriding VisitBinary to do some work, then simply return node; without either calling base.VisitBinary(node) or manually recursing into node.Left/node.Right. The walk doesn't crash — it just silently never reaches anything below that node, which is a subtle, quiet bug rather than an obvious one. ConstantCollector's call to base.VisitConstant(node) and PrettyPrinter's manual Visit(node.Left)/Visit(node.Right) calls are both, in their own way, exactly this concern being handled correctly.

2. "This lesson's visitors are basically what EF Core does" — they're the same category of technique, not the same scale

Worth restating plainly, since it's easy to over-extrapolate from a small, fully-understood example: EF Core's real query pipeline involves query compilation caching, a full relational model, provider-specific SQL generation, and a great deal of correctness and performance engineering this lesson's ~15-line visitors don't attempt. What transfers directly is the technique — recursive visiting, recognizing node shapes, emitting equivalent fragments — not the claim that you've now seen EF Core's actual source.

Common Mistakes

Mistake 1 — Forgetting to continue the recursion inside an override

protected override Expression VisitConstant(ConstantExpression node) { Constants.Add(node.Value); return node; } — this happens to work fine for VisitConstant specifically, since constants have no children to recurse into. But apply the same pattern to VisitBinary ({ DoSomething(node); return node; }, with no call to base.VisitBinary or manual visits to Left/Right) and every node beneath that binary expression is silently skipped. For any node type that has children — binary expressions, method calls, lambdas, unary expressions — always either call base.VisitWhatever(node) or manually Visit(...) each child yourself, as PrettyPrinter's VisitBinary does.

Mistake 2 — Calling a specific VisitX method directly instead of the general Visit

myVisitor.VisitBinary(someBinaryExpression) called directly from outside the visitor — this works for that one node, but bypasses the type-checking dispatch that Visit provides, and is easy to get wrong if the node's actual runtime type doesn't match what you assumed. Always start a walk with the general myVisitor.Visit(rootNode), exactly as both examples in this lesson do — let the dispatch mechanism route to the correct specific method.

When Should I Use It?

Write a custom ExpressionVisitor when

Skip writing one when

Rule of thumb: Reach for ExpressionVisitor when you need to read or transform a tree yourself, rather than just execute it. If "execute it" is the actual goal, .Compile() or an existing provider is almost always simpler and more correct than a hand-rolled visitor.

Mental Model

ExpressionVisitor = "a tour guide that walks every node of a tree for you"
Overriding a VisitX method = "notice this specific kind of room, then let the tour continue"
Forgetting base/manual recursion = "the tour silently stops the moment it enters a room you gave instructions for"

Remember:
· Call the general Visit(root) once — the base class's dispatch routes to the right specific method for every node.
· Override only the node types you care about; everything else keeps using the base class's correct default recursion.
· Inside an override, either call base.VisitWhatever or manually Visit each child — or the walk silently stops there.
· This is the exact category of technique a real LINQ provider uses — recognize a node shape, emit an equivalent fragment — just at a vastly larger, more careful scale.

Key Takeaway


Check Your Understanding

You've built two working visitors and traced exactly how they walk a tree. Let's check your understanding.

1. What does ExpressionVisitor's general Visit(Expression node) method actually do?

Show answer

Correct: B

Why B is correct: As Under the Hood explained, Visit's real implementation checks the node's NodeType and routes to the correct specific VisitX method — this dispatch is exactly what lets you call one general method and have it correctly handle every node type in the tree.

Why A is incorrect: Visiting a tree and compiling it (.Compile(), from lesson 188) are two entirely separate operations — a visitor reads and optionally rebuilds a tree; it doesn't emit IL.

Why C is incorrect: Nothing about visiting deletes anything by default — the base implementations simply recurse and, unless you override behavior to change something, rebuild an equivalent tree.

Why D is incorrect: Visit is called recursively on every child node throughout the tree, by design — that's the entire mechanism that makes a single top-level call sufficient.

Reinforcement: One general dispatch method, routing by runtime node type, is the whole trick behind ExpressionVisitor's convenience.

2. In ConstantCollector, why does VisitConstant call base.VisitConstant(node) at the end, after recording node.Value?

Show answer

Correct: B

Why B is correct: As Common Confusion point 1 and How It Works both explained, calling base.VisitX is the safe, idiomatic habit for continuing whatever default behavior the base class provides — even though a ConstantExpression has no children to recurse into, so this specific call happens to be a no-op beyond returning the node unchanged.

Why A is incorrect: C# doesn't require calling the base implementation when overriding a virtual method — it's optional, and the choice matters semantically, not syntactically.

Why C is incorrect: The two lines do unrelated jobs — recording the value is this visitor's own logic; calling base is about correctly continuing the visit, not undoing anything.

Why D is incorrect: Visiting and compiling are unrelated operations — no compilation happens anywhere inside a visitor's walk.

Reinforcement: Calling base.VisitX is the safe default habit — it happens to be a no-op here only because constants have no children, not because the habit itself is unnecessary elsewhere.

3. In PrettyPrinter's VisitBinary override, why does it manually call Visit(node.Left) and Visit(node.Right) instead of calling base.VisitBinary(node) at the end?

Show answer

Correct: B

Why B is correct: As the Simple Example's explanation walked through, PrettyPrinter needs to append the left side's output, then the operator symbol, then the right side's output, in that exact interleaved order — something only achievable by controlling the recursion manually, calling Visit on each side exactly where its output needs to appear in the string.

Why A is incorrect: base.VisitBinary is a real, valid method — ExpressionVisitor provides a working default implementation for every node type, including binary expressions.

Why C is incorrect: base.VisitX calls work correctly and are used elsewhere in this very lesson (ConstantCollector) — manual recursion is a choice made for a specific reason here, not a universal requirement.

Why D is incorrect: The difference is functionally significant — calling base.VisitBinary(node) afterward would recurse into both children a second time, producing duplicated or malformed output.

Reinforcement: Choose manual recursion over base.VisitX specifically when you need control over ordering or formatting around each child's visit.

4. Which statement most accurately describes this lesson's relationship to how EF Core's real LINQ provider works?

Show answer

Correct: B

Why B is correct: This is explicitly the honest framing this lesson commits to throughout — the Real-World Example and Common Confusion both state directly that the technique transfers, while the scale and correctness engineering of a real provider does not.

Why A is incorrect: EF Core's provider handles table/column mapping, parameterization, dozens of operators, multiple SQL dialects, and correctness/security concerns this lesson's two small classes make no attempt to cover.

Why C is incorrect: Expression tree walking (via ExpressionVisitor or a similar mechanism) is genuinely how LINQ providers, including EF Core, read the query you wrote — this lesson's technique is the same underlying category of work.

Why D is incorrect: The reverse is true — EF Core's real provider is substantially larger and more complex than these two teaching-sized examples, precisely because of everything named in the correct answer.

Reinforcement: Same technique, wildly different scale and rigor — that's the honest, accurate relationship this lesson draws.

You now understand both sides of a translatable LINQ query — building the tree (lesson 197) and reading it (this lesson). Next: what "deferred" really means once you have both worlds — iterators and expression trees — to compare side by side.


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