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.
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.
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."
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.
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.
p => p.Price > 100 && p.InStock — the same shape, three very different visitors:100, true
(p.Price > 100) AND p.InStock
WHERE [p].[Price] > 100 AND [p].[InStock] = 1
class MyVisitor : ExpressionVisitor — this alone already gives you a fully working (if useless on its own) visitor that walks any tree and rebuilds an identical copy of it, touching nothing.VisitConstant to react to every literal value in the tree. Override VisitMethodCall to react to calls like Where(...) or string.Contains(...). Leave everything else untouched — the base class's default implementation keeps recursing into children correctly on your behalf.base.VisitWhatever(node) (or manually call Visit on the node's children) to make sure the walk continues past this node, deeper into the tree. Forgetting this silently truncates the walk at the first node you override.VisitBinary or VisitConstant yourself from outside the visitor — you call the general Visit(rootNode) exactly once, and the base class's internal dispatch (checking each node's runtime type) routes to the right specific method, all the way down, automatically.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
// 100Code → 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 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).
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."
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.
Expression instance carries a NodeType property (an ExpressionType enum value — GreaterThan, Constant, Call, and so on). The base Visit method's real implementation is effectively a large switch on that value, routing to VisitBinary, VisitConstant, VisitMethodCall, or whichever specific method matches — which is exactly why calling the single, general Visit is enough; you never need your own dispatch logic.Expression subtype is immutable, exactly as lesson 188 established. This is why every VisitX method has the signature "takes a node, returns a node" rather than "takes a node, mutates it": ExpressionVisitor's default implementations don't just walk the tree read-only — they're actually capable of building an entirely new, modified tree as they go (recursively visiting children, then constructing a new parent node from whatever the children's visits returned), which is how more advanced visitors (rewriting one method call into a different one, for instance) work. This lesson's two examples only ever return the original node unchanged, using the visitor purely for its side effects (collecting values, building a string) — but the mechanism supports genuine tree transformation too.string.Contains, DateTime arithmetic, and more), optimize the resulting SQL, and gracefully report exactly which part of a query couldn't be translated when one can't be. That's a genuinely large, carefully engineered subsystem — this lesson's honest scope is teaching you the underlying technique (visit, recognize, emit), not claiming to shrink that subsystem down to two small classes.base doesThe 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.
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.
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.
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.
ExpressionVisitor whenExpression<T>.Compile() (lesson 188) or an existing LINQ provider (EF Core, lesson 197) already does the walking for you.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.
base/manual recursion = "the tour silently stops the moment it enters a room you gave instructions for"Visit(root) once — the base class's dispatch routes to the right specific method for every node.base.VisitWhatever or manually Visit each child — or the walk silently stops there.ExpressionVisitor is a base class providing correct recursive walking of any expression tree, one virtual VisitX method per node type.VisitConstant, VisitBinary, VisitMethodCall, and so on; everything else keeps recursing correctly via the inherited default.base.VisitWhatever(node) or manually recurse into the node's children — forgetting this silently truncates the walk, the single most common bug.ConstantCollector and PrettyPrinter are small, fully-understood, working examples of the same category of technique a real LINQ provider like EF Core uses at a much larger scale — walk, recognize, emit an equivalent fragment.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?
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?
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?
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?
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.