Execution Engine Part 1 of 7
Part 1 · Expressions

Evaluating one expression against one row

The smallest job a query engine has: given a row and an expression like age + 10 > 40 AND city = 'Austin', produce a value. Get this right, including NULL, and everything after it is about deciding which rows to feed it.

A query engine does a lot: it scans tables, joins them, groups, sorts, and picks among a combinatorial number of plans. Underneath all of that, though, the innermost loop keeps asking one question: what does this expression evaluate to for this row? A WHERE clause asks it once per row to decide whether to keep the row. A SELECT list asks it once per output column. Join conditions, sort keys, and group keys ask it too.

So that's where we start. By the end of this post we'll have a representation for values and rows, a tree for expressions, an evaluator that walks the tree, SQL's peculiar three-valued logic, and a second evaluator that's faster because it does its thinking once instead of once per row.

Values, rows, schemas

Our engine has five kinds of value: NULL, integers, floats, strings, and booleans. In Go a value is any holding nil, int64, float64, string, or bool. In Clojure it's nil, a long, a double, a String, or a Boolean. No dates, no decimals, no collations. Those are real work in a real engine and none of it teaches anything about execution.

A row is a positional list of values. A schema is the matching list of column names, qualified by table: customers.id, customers.name, and so on. Here is the toy table that appears all through this series:

customers.idcustomers.namecustomers.citycustomers.age
1AdaAustin36
2BoBostonNULL
3CyAustin52
4DiDenver29

Why positional rather than a map from column name to value? Because every operator downstream will touch every row, and "look up age in a hash map" is a cost you'd pay billions of times. "Read slot 3" is an array index. Names live in the schema, which you consult once, when you build the thing that will run. That split between work done once per query and work done once per row comes back again and again, and it is the main idea in this post.

Expressions are trees

A parser turns age + 10 > 40 AND city = 'Austin' into a tree. Operators are interior nodes and columns and literals are leaves:

AND
├── >
│   ├── +
│   │   ├── Col("age")
│   │   └── Lit(10)
│   └── Lit(40)
└── =
    ├── Col("city")
    └── Lit("Austin")

We don't need a parser yet. The integrated engine has one, but here we build trees by hand. In Go each node is a struct implementing an Expr interface: Col{Name}, Lit{V}, Bin{Op, L, R}, Not{E}, IsNull{E}, and Func{Name, Args}. In Clojure we use plain vectors:

[:and [:> [:+ [:col "customers.age"] [:lit 10]] [:lit 40]]
      [:= [:col "customers.city"] [:lit "Austin"]]]

That's the whole data model: an expression is data describing a computation. What remains is running it.

Walking the tree

The obvious evaluator is a recursive function with one case per node type. To evaluate a node, evaluate its children, then combine the results. That's a post-order traversal:

function eval(expr, row, schema)
  match expr
    case Col(name)    -> return row[indexOf(schema, name)]   -- name lookup, every time
    case Lit(v)       -> return v
    case Bin(op, l, r) ->
      a = eval(l, row, schema)
      b = eval(r, row, schema)
      return apply(op, a, b)
    case Not(e)       -> return not3(eval(e, row, schema))
    case IsNull(e)    -> return eval(e, row, schema) is null

Step through it below. Pick a row and an expression and watch values move up the tree. Then pick Bo and watch what his NULL age does.

Two things are worth noticing. First, with Ada the second and third expressions finish early. If the left side of an OR is TRUE, the right side can't change the answer, so a sensible evaluator doesn't compute it. Second, with Bo, the NULL doesn't stop at the age leaf: NULL + 10 is NULL, NULL > 40 is NULL, and then the AND has to decide what "unknown and true" means.

NULL, and why SQL has three truth values

NULL in SQL doesn't mean "nothing" or "zero." It means unknown. Bo has an age; we just don't know it. Once you read it that way, the rules all follow from one question: could the answer change depending on what the unknown value turns out to be?

Then comes the rule that bites in practice: a WHERE clause keeps a row only when the predicate is TRUE. NULL is not TRUE, so NULL rows are dropped, just as FALSE ones are. That makes WHERE p and WHERE NOT p not complements of each other: a row where p is NULL appears in neither result. Try it:

In code, three-valued AND and OR are small enough to read at a glance. Note the order of the checks: the decisive value wins over NULL.

function and3(a, b)
  if a == false or b == false then return false   -- decisive, even if the other is NULL
  if a == null or b == null then return null
  return true

function or3(a, b)
  if a == true or b == true then return true
  if a == null or b == null then return null
  return false

function passes(predicate, row)
  return eval(predicate, row) == true              -- not "truthy": exactly TRUE

Short-circuiting follows directly from these definitions. If the left side of an AND is FALSE, you can return FALSE without evaluating the right side. If the left side is NULL you can't stop, because a FALSE on the right would turn the result into FALSE. So evaluate the right side as well.

The rest of the semantics

Every engine has to pick answers for the edge cases. Here are ours, as written down in the project's design doc:

Compiling the tree into closures

The tree walker works, but look at what it repeats for every row. For each node, it re-decides which kind of node this is (the match). For every column, it searches the schema for the column's name. Neither answer ever changes between rows. Both are decisions about the query, and we're making them over and over at row time.

The fix is to walk the tree once, make every decision that doesn't depend on the row, and return a function that only does the remaining per-row work. Each node becomes a closure that has already captured its children's closures and its resolved column index:

function compile(expr, schema) -> (row -> value)
  match expr
    case Col(name) ->
      i = indexOf(schema, name)            -- resolved once, here
      if i < 0 then error("unknown column " + name)
      return row -> row[i]
    case Lit(v) ->
      return row -> v
    case Bin("and", l, r) ->
      fl = compile(l, schema); fr = compile(r, schema)
      return row ->
        a = fl(row)
        if a == false then return false     -- short-circuit
        return and3(a, fr(row))
    case Bin(op, l, r) ->
      fl = compile(l, schema); fr = compile(r, schema)
      f  = lookupOperator(op)               -- also resolved once
      return row -> f(fl(row), fr(row))

A few things changed beyond speed. An unknown column is now an error at compile time, before any row is read. The per-row path doesn't mention the schema at all. And the structure of the work changed: the tree is still there, but it now exists as a tree of closures pointing at closures, and evaluation is a chain of plain function calls.

In Clojure this is especially natural, since compile returns fns:

(defn compile-expr [expr schema]
  (case (first expr)
    :col (let [i (.indexOf ^java.util.List schema (second expr))]
           (fn [row] (nth row i)))
    :lit (let [v (second expr)] (fn [_] v))
    :and (let [l (compile-expr (nth expr 1) schema)
               r (compile-expr (nth expr 2) schema)]
           (fn [row] (let [a (l row)]
                       (if (false? a) false (and3 a (r row))))))
    …))

How much faster is it? On the JVM, with the JIT inlining small closures, often several times for simple predicates. The exact number doesn't matter here. What matters is the habit of separating the work that depends only on the query from the work that depends on the row. Parts 6 and 7 push that habit much further: the planner makes even more decisions up front, and the final part compiles a whole query, not just one expression.

This is the "closure compilation" trick from interpreter folklore. It sits in the space between a naive AST interpreter and real code generation, and it's how many embedded engines evaluate expressions. Production systems go further and generate bytecode or machine code per query (Spark's whole-stage codegen, HyPer and Umbra via LLVM), or they amortize the per-row cost by evaluating each node over a batch of a thousand values at once (DuckDB, vectorized engines generally).

Where does the work in progress live?

Here's a question that looks pointless now but will matter by part 7: while an expression is halfway through evaluation, where is its state? In the tree walker, it's on the call stack: the left child's result sits in a local variable of the parent's stack frame while the right child is evaluated. The compiled version is the same; the nested closure calls hold intermediate values in their frames.

For expressions that's fine, since they run for nanoseconds. Nobody wants to pause halfway through age + 10. A whole query is another matter: it can run for minutes, and "paused partway through" becomes a feature you might want. In the next part, operators get their own state, and we'll start keeping track of which state lives in objects on the heap and which lives in stack frames.

sqlglot's executor also compiles each expression once per query, but to Python source rather than closures. It generates a string like scope["customers"]["age"] + 10 > 40, compiles it with Python's compile, and runs it with eval for every row against a Context. Column access goes through a RowReader, a single reusable cursor object whose row slot is repointed at each row, so reading a column is a dict lookup to an index followed by a tuple index. It's the same split between once per query and once per row, with Python's compiler doing the "compile" step.

go/lesson01/expressions.go · clojure/src/lesson01/expressions.clj (ns lesson01.expressions). Both have the AST, the tree-walking Eval, the closure-compiling Compile, and 3VL helpers. The demos evaluate the predicates from this post over the toy table both ways: go run ./cmd/lesson01 / clojure -M -m lesson01.expressions.

Or run it right here: open the terminal (the Go demo, compiled to WebAssembly).

The exercise track for this part has the reference lesson with the interesting function bodies replaced by TODOs, and tests that fail until you fill them in. For this part: eval-binary, and-or-3vl, compile-closures, and in Clojure eval-tree (see the exercises page for the current list and hints).

cd go && go test ./exercises/lesson01/
cd clojure && clojure -M:ex -d ex -n exercises.lesson01.expressions-test

An expression is a tree. Evaluate it bottom-up; treat NULL as "unknown" and three-valued logic follows; keep a row only on exactly TRUE. Then move every decision that doesn't depend on the row (node type, column position, operator) out of the per-row path by compiling the tree once.