Skip to content

PostgreSQL Expression Evaluation — Flat Step Array, Dispatch Loop, and Fast Paths

Contents:

Every relational engine has an expression evaluator: the component that takes a scalar expression — a comparison, a function call, a constant, a column reference — and computes its value for a given tuple. The evaluator is called millions of times per query and sits on the hot path of scan filters, join predicates, projection, and aggregate transition functions. Its performance directly bounds query throughput.

Database System Concepts (Silberschatz, 7e, ch. 15 “Query Processing”) describes expression evaluation in the context of pipelined query execution. When tuples flow through an operator tree, each operator applies expressions to its input tuples: a SELECT applies a projection expression, a WHERE applies a predicate expression, a hash join applies a hash function expression. The textbook models these as straightforward “evaluate a function on tuple attributes,” but the engineering challenge is that function calls in SQL can be recursive (expressions contain sub-expressions), variable-arity, and nullable — the SQL three-valued logic means every value carries a null flag alongside its Datum.

Database Internals (Petrov, ch. 7 “Query Processing”) notes two broad evaluation strategies:

  1. Recursive tree walk. Represent the expression as a tree of nodes; evaluate by calling a virtual eval() on the root, which recurses into children. Conceptually clean but pays function-call and pointer-dispatch overhead at every node, every tuple.

  2. Compiled / flat bytecode. Reduce the expression tree to a linear sequence of instructions at plan time. Execute the sequence in a tight loop at runtime — no recursion, no per-node dispatch overhead, better branch prediction. This is the approach compilers use for arithmetic expression code generation and the approach modern database engines increasingly adopt.

PostgreSQL’s current expression evaluator (introduced in PG 10, replacing the older ExprContext-recursion model) is firmly in the second camp. The compiler phase (ExecInitExpr, ExecInitExprRec) transforms the planner’s Expr tree into a flat ExprEvalStep array; the runtime phase (ExecInterpExpr) iterates over that array with a dispatch loop. The executor README summarises the rationale:

“commonly the amount of work needed to evaluate one Expr-type node is small enough that the overhead of having to perform a tree-walk during evaluation is significant … the flat representation can be evaluated non-recursively within a single function, reducing stack depth and function call overhead … such a representation is usable both for fast interpreted execution, and for compiling into native code.”

That last point is key: the same flat step array is what the LLVM JIT compiler (jit/llvm/) operates on — interpreted and JIT paths share a unified representation.

Most production query engines distinguish a compilation phase (at plan time or executor startup) from an execution phase (per tuple). The compilation phase does the expensive work once — type lookup, function resolution, null-semantics analysis, argument slot allocation — and the execution phase re-uses the prepared state. This amortises plan-time overhead across all tuples in a scan.

Within the execution phase, engines differ on the dispatch mechanism:

  • Virtual function / vtable dispatch. Each expression node type has a method pointer; dispatch is node->ops->eval(node, ctx). Used by older PostgreSQL (pre-PG10) and many embedded expression engines. Friendly to object-oriented implementation but pays an indirect branch per node.

  • Switch-based dispatch. A flat integer opcode drives a switch statement. The compiler can generate a jump table; all dispatches route through a single indirect branch. Lower overhead than per-node vtable dispatch, but branch prediction degrades because the branch site is one location.

  • Computed-goto (direct-threaded) dispatch. Replace each opcode integer with the address of the label implementing it (a GCC extension). Each instruction ends with a computed goto to the next instruction’s address. Dispatch happens from many different branch sites, giving the CPU’s branch predictor more context per opcode. This is the fastest portable dispatch strategy short of JIT compilation. PostgreSQL uses it on GCC and Clang when HAVE_COMPUTED_GOTO is defined.

  • JIT to native code. LLVM or similar lowers the step sequence to machine code, eliminating the interpreter loop entirely for sufficiently complex or repeated expressions. PostgreSQL supports LLVM JIT (PG11+, in-tree under jit/llvm/) behind jit = on.

The null-handling contract is universal: every expression result is a pair (Datum value, bool isnull). Strict functions return NULL when any argument is NULL without calling the actual function. The evaluator must enforce this cheaply. PostgreSQL does so by specialising the function-call opcodes: EEOP_FUNCEXPR_STRICT_1 and EEOP_FUNCEXPR_STRICT_2 handle the common 1- and 2-argument strict cases with inline null checks rather than a loop.

Representation: ExprState and ExprEvalStep

Section titled “Representation: ExprState and ExprEvalStep”

Every separately executable expression is represented by one ExprState node. The ExprState contains:

// ExprState — src/include/nodes/execnodes.h (condensed)
typedef struct ExprState
{
NodeTag type;
uint8 flags; /* EEO_FLAG_* bitmask */
bool resnull; /* result null flag (scalar expressions) */
Datum resvalue; /* result value (scalar expressions) */
TupleTableSlot *resultslot; /* result slot (projection expressions) */
struct ExprEvalStep *steps; /* flat instruction array */
ExprStateEvalFunc evalfunc; /* dispatch: JIT fn, fast-path, or ExecInterpExpr */
Expr *expr; /* original tree (debug only) */
void *evalfunc_private; /* fast-path or JIT function pointer */
int steps_len;
int steps_alloc;
struct PlanState *parent;
/* ... compilation temporaries ... */
} ExprState;

Each entry in steps[] is an ExprEvalStep — a fixed-size struct that fits in one 64-byte cache line:

// ExprEvalStep — src/include/executor/execExpr.h (condensed)
typedef struct ExprEvalStep
{
intptr_t opcode; /* enum ExprEvalOp, or label address after threading */
Datum *resvalue; /* where to write this step's result Datum */
bool *resnull; /* where to write this step's null flag */
union {
/* EEOP_INNER/OUTER/SCAN_FETCHSOME */
struct { int last_var; bool fixed; TupleDesc known_desc;
const TupleTableSlotOps *kind; } fetch;
/* EEOP_INNER/OUTER/SCAN_VAR */
struct { int attnum; Oid vartype; VarReturningType varreturningtype; } var;
/* EEOP_FUNCEXPR_* */
struct { FmgrInfo *finfo; FunctionCallInfo fcinfo_data;
PGFunction fn_addr; int nargs; bool make_ro; } func;
/* EEOP_BOOL_*_STEP */
struct { bool *anynull; int jumpdone; } boolexpr;
/* EEOP_QUAL / EEOP_JUMP* */
struct { int jumpdone; } qualexpr;
/* EEOP_CONST */
struct { Datum value; bool isnull; } constval;
/* ... ~30 more union arms ... */
} d;
} ExprEvalStep;

The opcode field starts as an ExprEvalOp enum value. After ExecReadyInterpretedExpr runs in direct-threaded mode, each opcode is replaced with the GCC label address of the corresponding EEO_CASE block, and EEO_FLAG_DIRECT_THREADED is set in state->flags. The enum value can be recovered via ExecEvalStepOp() for debugging.

The ExprEvalOp enum defines ~110 opcodes. Key groups:

GroupRepresentative opcodesPurpose
Slot prefetchEEOP_INNER/OUTER/SCAN/OLD/NEW_FETCHSOMEcall slot_getsomeattrs once per slot per expression
Var fetchEEOP_INNER/OUTER/SCAN_VAR, EEOP_SCAN_SYSVARread one attribute from a pre-fetched slot
Var assignEEOP_ASSIGN_INNER/OUTER/SCAN_VAR, EEOP_ASSIGN_TMPwrite into resultslot->tts_values/nulls
Function callEEOP_FUNCEXPR, EEOP_FUNCEXPR_STRICT, EEOP_FUNCEXPR_STRICT_1, EEOP_FUNCEXPR_STRICT_2, EEOP_FUNCEXPR_FUSAGEcall fmgr function with pre-wired fcinfo
BooleanEEOP_BOOL_AND/OR_STEP_FIRST/LAST, EEOP_BOOL_NOT_STEP, EEOP_QUALshort-circuit AND/OR/NOT; QUAL is AND with null-as-false
JumpEEOP_JUMP, EEOP_JUMP_IF_NULL/NOT_NULL/NOT_TRUEconditional skip over sub-steps
ConstantEEOP_CONSTload a pre-computed Datum
AggregationEEOP_AGG_PLAIN_TRANS_*, EEOP_AGG_STRICT_*aggregate transition steps, inlined for byval/byref × strict/non-strict
DoneEEOP_DONE_RETURN, EEOP_DONE_NO_RETURNterminate the step loop

Compilation: ExecInitExpr → ExecInitExprRec

Section titled “Compilation: ExecInitExpr → ExecInitExprRec”

ExecInitExpr allocates an ExprState, inserts FETCHSOME setup steps, calls ExecInitExprRec recursively, appends EEOP_DONE_RETURN, and calls ExecReadyExpr:

// ExecInitExpr — src/backend/executor/execExpr.c (condensed)
ExprState *
ExecInitExpr(Expr *node, PlanState *parent)
{
ExprState *state = makeNode(ExprState);
ExprEvalStep scratch = {0};
state->expr = node;
state->parent = parent;
ExecCreateExprSetupSteps(state, (Node *) node); /* emit FETCHSOME steps */
ExecInitExprRec(node, state, &state->resvalue, &state->resnull);
scratch.opcode = EEOP_DONE_RETURN;
ExprEvalPushStep(state, &scratch);
ExecReadyExpr(state);
return state;
}

ExecInitExprRec is the main recursive compiler. It switches on the Expr node type and emits the appropriate opcodes. For a T_Var:

// ExecInitExprRec (T_Var branch) — src/backend/executor/execExpr.c (condensed)
case T_Var:
{
Var *variable = (Var *) node;
scratch.d.var.attnum = variable->varattno - 1; /* 0-based */
scratch.d.var.vartype = variable->vartype;
switch (variable->varno)
{
case INNER_VAR: scratch.opcode = EEOP_INNER_VAR; break;
case OUTER_VAR: scratch.opcode = EEOP_OUTER_VAR; break;
default: scratch.opcode = EEOP_SCAN_VAR; break;
}
ExprEvalPushStep(state, &scratch);
break;
}

For T_FuncExpr / T_OpExpr, ExecInitFunc handles argument wiring and opcode selection:

// ExecInitFunc — src/backend/executor/execExpr.c (condensed)
scratch->d.func.finfo = palloc0(sizeof(FmgrInfo));
scratch->d.func.fcinfo_data = palloc0(SizeForFunctionCallInfo(nargs));
fmgr_info(funcid, scratch->d.func.finfo);
InitFunctionCallInfoData(*fcinfo, flinfo, nargs, inputcollid, NULL, NULL);
scratch->d.func.fn_addr = flinfo->fn_addr; /* hot copy to avoid indirection */
/* emit argument sub-steps directly into fcinfo->args[argno] */
foreach(lc, args) {
ExecInitExprRec(arg, state,
&fcinfo->args[argno].value,
&fcinfo->args[argno].isnull); /* args land in-place */
argno++;
}
/* pick opcode based on strictness × stats tracking × nargs */
if (pgstat_track_functions <= flinfo->fn_stats) {
if (flinfo->fn_strict && nargs > 0) {
if (nargs == 1) scratch->opcode = EEOP_FUNCEXPR_STRICT_1;
else if (nargs == 2) scratch->opcode = EEOP_FUNCEXPR_STRICT_2;
else scratch->opcode = EEOP_FUNCEXPR_STRICT;
} else
scratch->opcode = EEOP_FUNCEXPR;
} else {
scratch->opcode = flinfo->fn_strict ? EEOP_FUNCEXPR_STRICT_FUSAGE
: EEOP_FUNCEXPR_FUSAGE;
}

The key insight: argument sub-steps write their results directly into fcinfo->args[argno].value / .isnull. There are no temporaries, no copies — the function call step finds its arguments already in the right memory.

ExecInitQual builds a specialised ExprState for WHERE-clause conjuncts. It uses EEOP_QUAL instead of EEOP_BOOL_AND_STEP: QUAL treats NULL as false, enabling short-circuit without tracking the anynull accumulator:

// ExecInitQual — src/backend/executor/execExpr.c (condensed)
state->flags = EEO_FLAG_IS_QUAL;
scratch.opcode = EEOP_QUAL;
foreach_ptr(Expr, node, qual)
{
ExecInitExprRec(node, state, &state->resvalue, &state->resnull);
scratch.d.qualexpr.jumpdone = -1; /* placeholder */
ExprEvalPushStep(state, &scratch);
adjust_jumps = lappend_int(adjust_jumps, state->steps_len - 1);
}
/* back-patch: all QUAL jumpdone targets point past the last step */
foreach_int(jump, adjust_jumps)
state->steps[jump].d.qualexpr.jumpdone = state->steps_len;

The back-patching pattern is used everywhere jump targets are needed: emit a step with jumpdone = -1, complete the sub-steps, then fill in the real target index. Boolean AND/OR expressions use the same technique.

ExecBuildProjectionInfo compiles a targetlist into an ExprState that writes into resultslot. Simple Var entries use EEOP_ASSIGN_*_VAR (one step: copy directly from source slot to result slot). Complex entries use the two-step pattern: evaluate expression into state->resvalue / resnull, then EEOP_ASSIGN_TMP to copy into resultslot->tts_values[col].

ExecReadyExpr: JIT gate and fast-path selection

Section titled “ExecReadyExpr: JIT gate and fast-path selection”

ExecReadyExpr is the final step of compilation:

// ExecReadyExpr — src/backend/executor/execExpr.c
static void
ExecReadyExpr(ExprState *state)
{
if (jit_compile_expr(state)) /* returns true if JIT took it */
return;
ExecReadyInterpretedExpr(state);
}

ExecReadyInterpretedExpr first checks for simple patterns and installs dedicated ExecJust* functions that bypass the interpreter loop entirely:

Pattern (steps_len)Installed evalfunc
2: EEOP_CONSTExecJustConst
3: EEOP_SCAN_FETCHSOME + EEOP_SCAN_VARExecJustScanVar
3: EEOP_INNER_FETCHSOME + EEOP_INNER_VARExecJustInnerVar
3: EEOP_SCAN_FETCHSOME + EEOP_ASSIGN_SCAN_VARExecJustAssignScanVar
3: EEOP_CASE_TESTVAL + EEOP_FUNCEXPR_STRICT*ExecJustApplyFuncToCase
2: EEOP_INNER_VAR (no fetch)ExecJustInnerVarVirt

If no fast-path matches, it replaces each opcode integer with the GCC label address (EEO_OPCODE macro, EEO_USE_COMPUTED_GOTO) and sets evalfunc_private = ExecInterpExpr. The expression is then evaluated by calling state->evalfunc(state, econtext, &isnull), which on the first call goes through ExecInterpExprStillValid (a one-time schema-change check) before delegating to the real evalfunc.

ExecInterpExpr is the interpreter. With computed gotos:

// ExecInterpExpr — src/backend/executor/execExprInterp.c (condensed)
static Datum
ExecInterpExpr(ExprState *state, ExprContext *econtext, bool *isnull)
{
ExprEvalStep *op = state->steps; /* current instruction pointer */
/* ... local slot pointer cache ... */
EEO_DISPATCH(); /* jump to first instruction (computed goto or switch) */
EEO_CASE(EEOP_DONE_RETURN)
{
*isnull = state->resnull;
return state->resvalue;
}
EEO_CASE(EEOP_INNER_FETCHSOME)
{
slot_getsomeattrs(innerslot, op->d.fetch.last_var);
EEO_NEXT(); /* op++; EEO_DISPATCH() */
}
EEO_CASE(EEOP_SCAN_VAR)
{
int attnum = op->d.var.attnum;
*op->resvalue = scanslot->tts_values[attnum]; /* direct array read */
*op->resnull = scanslot->tts_isnull[attnum];
EEO_NEXT();
}
EEO_CASE(EEOP_FUNCEXPR)
{
FunctionCallInfo fcinfo = op->d.func.fcinfo_data;
Datum d;
fcinfo->isnull = false;
d = op->d.func.fn_addr(fcinfo); /* direct call via cached fn_addr */
*op->resvalue = d;
*op->resnull = fcinfo->isnull;
EEO_NEXT();
}
EEO_CASE(EEOP_FUNCEXPR_STRICT_2)
{
FunctionCallInfo fcinfo = op->d.func.fcinfo_data;
NullableDatum *args = fcinfo->args;
if (args[0].isnull || args[1].isnull) /* skip call if either NULL */
*op->resnull = true;
else {
Datum d;
fcinfo->isnull = false;
d = op->d.func.fn_addr(fcinfo);
*op->resvalue = d;
*op->resnull = fcinfo->isnull;
}
EEO_NEXT();
}
EEO_CASE(EEOP_QUAL)
{
if (*op->resnull || !DatumGetBool(*op->resvalue))
EEO_JUMP(op->d.qualexpr.jumpdone); /* false or null → short-circuit */
EEO_NEXT();
}
/* ... ~110 more cases ... */
}

EEO_NEXT increments op and dispatches to the next step. EEO_JUMP sets op = &state->steps[target] and dispatches there. EEO_DISPATCH is a goto *op->opcode in direct-threaded mode and switch(op->opcode) fallthrough in portable mode.

The dispatch macros: one source, two strategies

Section titled “The dispatch macros: one source, two strategies”

The single most important trick in execExprInterp.c is that the same C source compiles to either a computed-goto threaded interpreter or a portable switch interpreter, selected by whether the platform defines HAVE_COMPUTED_GOTO. A small macro family hides the difference so every opcode body is written once:

// EEO_* dispatch macros — src/backend/executor/execExprInterp.c
#if defined(EEO_USE_COMPUTED_GOTO)
#define EEO_SWITCH()
#define EEO_CASE(name) CASE_##name:
#define EEO_DISPATCH() goto *((void *) op->opcode)
#define EEO_OPCODE(opcode) ((intptr_t) dispatch_table[opcode])
#else /* !EEO_USE_COMPUTED_GOTO */
#define EEO_SWITCH() starteval: switch ((ExprEvalOp) op->opcode)
#define EEO_CASE(name) case name:
#define EEO_DISPATCH() goto starteval
#define EEO_OPCODE(opcode) (opcode)
#endif
#define EEO_NEXT() \
do { op++; EEO_DISPATCH(); } while (0)
#define EEO_JUMP(stepno) \
do { op = &state->steps[stepno]; EEO_DISPATCH(); } while (0)

In the computed-goto build, EEO_CASE(EEOP_FOO) expands to the label CASE_EEOP_FOO:, EEO_SWITCH() expands to nothing, and EEO_DISPATCH() is a goto * through the already-threaded op->opcode (which by this point holds a label address, not an enum). In the portable build the very same EEO_CASE becomes case EEOP_FOO: inside a real switch, and EEO_DISPATCH() is a goto starteval back to the switch head. Because EEO_NEXT/EEO_JUMP embed EEO_DISPATCH() directly, in threaded mode the next dispatch happens from a distinct site after every opcode — that is what gives the branch predictor per-opcode history.

The computed-goto table is built lazily. ExecInitInterpreter calls ExecInterpExpr(NULL, ...); the interpreter detects the NULL sentinel and returns the address of its local dispatch_table[] (an array of &&LABEL GCC label-address values, one per opcode, declared in the exact order of the ExprEvalOp enum). A compile-time assertion guards that ordering:

// ExecInterpExpr — src/backend/executor/execExprInterp.c (condensed)
#if defined(EEO_USE_COMPUTED_GOTO)
static const void *const dispatch_table[] = {
&&CASE_EEOP_DONE_RETURN,
&&CASE_EEOP_DONE_NO_RETURN,
&&CASE_EEOP_INNER_FETCHSOME,
/* ... one &&label per opcode, in enum order ... */
&&CASE_EEOP_LAST
};
StaticAssertDecl(lengthof(dispatch_table) == EEOP_LAST + 1,
"dispatch_table out of whack with ExprEvalOp");
if (unlikely(state == NULL))
return PointerGetDatum(dispatch_table);
#endif

ExecReadyInterpretedExpr then rewrites every step’s opcode field from the enum value to dispatch_table[opcode] via the EEO_OPCODE macro and sets EEO_FLAG_DIRECT_THREADED. After that rewrite, the raw enum is unrecoverable from op->opcode directly; PostgreSQL keeps a reverse_dispatch_table (populated in ExecInitInterpreter) so ExecEvalStepOp() can map a label address back to its ExprEvalOp for EXPLAIN/JIT/debugging.

Slot setup and the per-call register cache

Section titled “Slot setup and the per-call register cache”

The first thing ExecInterpExpr does on a real call is cache the five input slots from the ExprContext into locals, so opcode bodies read them without re-dereferencing econtext each step:

// ExecInterpExpr — src/backend/executor/execExprInterp.c (condensed)
op = state->steps;
resultslot = state->resultslot;
innerslot = econtext->ecxt_innertuple;
outerslot = econtext->ecxt_outertuple;
scanslot = econtext->ecxt_scantuple;
oldslot = econtext->ecxt_oldtuple; /* REL_18: RETURNING OLD */
newslot = econtext->ecxt_newtuple; /* REL_18: RETURNING NEW */
#if defined(EEO_USE_COMPUTED_GOTO)
EEO_DISPATCH(); /* jump straight to first step */
#endif
EEO_SWITCH()
{
EEO_CASE(EEOP_DONE_RETURN) { *isnull = state->resnull; return state->resvalue; }
EEO_CASE(EEOP_DONE_NO_RETURN) { Assert(isnull == NULL); return (Datum) 0; }
/* ... */

oldslot/newslot are REL-18 additions feeding the EEOP_OLD_VAR / EEOP_NEW_VAR opcodes for RETURNING OLD/NEW. The two terminators differ by contract: EEOP_DONE_RETURN is for scalar expressions that yield a Datum; EEOP_DONE_NO_RETURN ends projection-style expressions whose entire output already landed in resultslot via EEOP_ASSIGN_*, and asserts the caller did not ask for a return Datum (isnull == NULL).

Short-circuit Boolean and back-patched jumps at runtime

Section titled “Short-circuit Boolean and back-patched jumps at runtime”

The compile-time back-patching of jumpdone (shown for EEOP_QUAL above) pays off in the Boolean opcodes. EEOP_BOOL_AND_STEP accumulates a NULL flag and jumps to the precomputed end-of-AND target the moment it sees a hard FALSE:

// ExecInterpExpr EEOP_BOOL_AND_STEP — src/backend/executor/execExprInterp.c
EEO_CASE(EEOP_BOOL_AND_STEP)
{
if (*op->resnull)
*op->d.boolexpr.anynull = true;
else if (!DatumGetBool(*op->resvalue))
EEO_JUMP(op->d.boolexpr.jumpdone); /* hard FALSE: skip rest */
EEO_NEXT();
}

The _FIRST variant initialises *anynull = false before the first conjunct; the _LAST variant resolves the accumulated NULL into the SQL three-valued result (NULL if any operand was NULL and none was FALSE). EEOP_QUAL is the WHERE-clause specialisation that needs no anynull accumulator because it collapses NULL to FALSE immediately.

Projection steps write straight into the result slot’s parallel arrays, with no intermediate Datum. The simple-Var case is a single load/store pair; the “computed into the temp” case uses EEOP_ASSIGN_TMP to move state->resvalue/resnull into the column:

// ExecInterpExpr EEOP_ASSIGN_TMP — src/backend/executor/execExprInterp.c
EEO_CASE(EEOP_ASSIGN_TMP)
{
int resultnum = op->d.assign_tmp.resultnum;
Assert(resultnum >= 0 && resultnum < resultslot->tts_tupleDescriptor->natts);
resultslot->tts_values[resultnum] = state->resvalue;
resultslot->tts_isnull[resultnum] = state->resnull;
EEO_NEXT();
}

EEOP_ASSIGN_TMP_MAKE_RO is the variant for expanded-datum (TOAST-expanded) results: it wraps a non-null value in MakeExpandedObjectReadOnlyInternal before storing, so the projected tuple cannot accidentally mutate a shared expanded object.

Fast-path internals: how ExecJust* skips the loop

Section titled “Fast-path internals: how ExecJust* skips the loop”

The fast-path functions are not just “the loop with one step” — they hand-roll the work so even the FETCHSOME step is implicit. ExecJustVarImpl (shared by ExecJustInnerVar/OuterVar/ScanVar) does a slot-compatibility check on step 0, then reads the attribute through slot_getattr, which deforms on demand and bounds-checks for free:

// ExecJustVarImpl — src/backend/executor/execExprInterp.c
static pg_attribute_always_inline Datum
ExecJustVarImpl(ExprState *state, TupleTableSlot *slot, bool *isnull)
{
ExprEvalStep *op = &state->steps[1];
int attnum = op->d.var.attnum + 1; /* back to 1-based for slot_getattr */
CheckOpSlotCompatibility(&state->steps[0], slot);
return slot_getattr(slot, attnum, isnull);
}

The *Virt siblings (ExecJustInnerVarVirt, etc.) handle the steps_len-2 case where the planner proved the slot is always a virtual slot — no FETCHSOME step was emitted at all, so the function reads tts_values[attnum] directly under Assert(TTS_IS_VIRTUAL(slot)). ExecJustConst is the absolute floor: it returns op->d.constval.value from step 0 with one null-flag store. ExecJustApplyFuncToCase fuses the EEOP_CASE_TESTVAL shuffle with a strict function call, inlining the per-arg NULL scan that the general EEOP_FUNCEXPR_STRICT path would run.

The first diagram covered the compilation pipeline (tree to flat steps). This second diagram zooms into the runtime side: how ExecInterpExpr threads from one ExprEvalStep to the next, where EEO_NEXT vs EEO_JUMP diverge, and where the loop exits.

flowchart TD
    A[ExecEvalExpr -> state-evalfunc] --> B{first call?}
    B -->|yes| C[ExecInterpExprStillValid<br/>recheck Var vs TupleDesc]
    C --> D[swap evalfunc to<br/>fast-path or ExecInterpExpr]
    B -->|no| D
    D --> E{fast-path<br/>installed?}
    E -->|yes| F[ExecJustConst / ExecJustScanVar<br/>ExecJustApplyFuncToCase ...<br/>no loop]
    F --> Z[return Datum + isnull]
    E -->|no| G[ExecInterpExpr:<br/>op = steps; cache 5 slots]
    G --> H[EEO_DISPATCH<br/>goto label or switch]
    H --> I{opcode}
    I -->|FETCHSOME| J[slot_getsomeattrs] --> K[EEO_NEXT: op++]
    I -->|SCAN_VAR / CONST| L[read into op-resvalue] --> K
    I -->|FUNCEXPR_STRICT_2| M{either arg NULL?}
    M -->|yes| N[resnull = true] --> K
    M -->|no| O[fn_addr fcinfo] --> K
    I -->|QUAL / BOOL_AND_STEP| P{false or null?}
    P -->|yes| Q[EEO_JUMP jumpdone] --> H
    P -->|no| K
    K --> H
    I -->|DONE_RETURN| Y[isnull = resnull] --> Z
    I -->|DONE_NO_RETURN| X[result already in<br/>resultslot tts_values] --> Z

Every ExprState is evaluated in an ExprContext (allocated by CreateExprContext). The key field is ecxt_per_tuple_memory: a short-lived memory context that is reset by ResetExprContext after each tuple is processed. All Datum allocations during expression evaluation live here unless explicitly copied out. Callers switch into this context before calling ExecEvalExpr:

// ExecEvalExpr — src/include/executor/executor.h (inline, condensed)
static inline Datum
ExecEvalExpr(ExprState *state, ExprContext *econtext, bool *isNull)
{
return state->evalfunc(state, econtext, isNull);
/* caller is already in ecxt_per_tuple_memory */
}

The convenience wrapper ExecEvalExprSwitchContext does the memory-context switch itself. ExecQual calls it, so WHERE-clause evaluations are safe regardless of what memory context the caller holds.

flowchart TD
    A[Expr tree\nfrom planner] -->|ExecInitExpr| B[ExprState\n+ steps array]
    B -->|ExecCreateExprSetupSteps| C[FETCHSOME\nsteps prepended]
    C -->|ExecInitExprRec| D[EEOP_* steps\nfor each node]
    D -->|ExecInitFunc| E[fcinfo wired\nargs in-place]
    D -->|ExecInitQual| F[EEOP_QUAL steps\n+ back-patch jumps]
    B -->|ExecReadyExpr| G{jit_compile_expr?}
    G -->|yes| H[LLVM JIT\nnative code]
    G -->|no| I[ExecReadyInterpretedExpr]
    I -->|fast-path match| J[ExecJust*\nbypasses loop]
    I -->|no match + computed goto| K[opcode -> label addr\nEEO_FLAG_DIRECT_THREADED]
    I -->|no match + switch| L[standard switch\ndispatch]
    K --> M[ExecInterpExpr\ndispatch loop]
    L --> M
    M -->|EEO_NEXT| M
    M -->|EEOP_DONE_RETURN| N[Datum result]

ExecInitExpr — entry point for scalar expressions; allocates ExprState, calls setup + rec + ready. ExecInitExprWithParams is the variant for standalone expressions with no parent PlanState.

ExecInitQual — entry point for WHERE-clause conjunct lists; sets EEO_FLAG_IS_QUAL and uses EEOP_QUAL opcode for null-as-false semantics.

ExecInitCheck — entry point for CHECK constraint conjuncts; converts implicit AND list to an explicit AND node so NULLs are treated as TRUE (SQL CHECK semantics differ from WHERE semantics).

ExecInitExprList — calls ExecInitExpr over a List, returns a List of ExprState *.

ExecBuildProjectionInfo — compiles a targetList into an ExprState that writes into a result TupleTableSlot. Embeds the ExprState inside ProjectionInfo (no extra palloc for the common projection case).

ExecInitExprRec — recursive compiler; switch on NodeTag. Handles all Expr subtypes: T_Var, T_Const, T_Param, T_FuncExpr, T_OpExpr, T_BoolExpr, T_CaseExpr, T_CoerceViaIO, T_SubscriptingRef, T_FieldStore, T_ScalarArrayOpExpr, T_SubPlan, T_Aggref, T_WindowFunc, etc.

ExecInitFunc — allocates FmgrInfo + FunctionCallInfoBaseData, calls fmgr_info, wires argument sub-expressions, and selects the right EEOP_FUNCEXPR_* opcode.

ExecCreateExprSetupSteps — walks the expression tree with expr_setup_walker to find the maximum attribute number needed from each tuple slot; emits EEOP_*_FETCHSOME steps at the front of the array.

ExprEvalPushStep — appends one ExprEvalStep to state->steps, repalloc-ing the array if needed. Because repalloc may move the array, callers must not hold live pointers into state->steps during compilation.

ExecReadyExpr — calls jit_compile_expr; if that returns false, calls ExecReadyInterpretedExpr.

ExecReadyInterpretedExpr — fast-path detection table (steps_len 2–5) then direct-threaded opcode patching, then sets evalfunc_private = ExecInterpExpr.

ExecInterpExpr — the main dispatch loop. When called with state == NULL (the sentinel for ExecInitInterpreter), returns the dispatch_table array address so opcodes can be patched with label addresses.

ExecInterpExprStillValid — first-call wrapper that checks whether tuple descriptors still match the compiled Var accesses, then replaces evalfunc with the fast path or ExecInterpExpr directly.

ExecJust* family — dedicated evalfuncs for the most common tiny expressions: ExecJustConst, ExecJustInnerVar, ExecJustOuterVar, ExecJustScanVar, ExecJustAssignInnerVar, ExecJustAssignScanVar, ExecJustInnerVarVirt, ExecJustScanVarVirt, and several hash-specific variants (ExecJustHashInnerVar, ExecJustHashOuterVar, etc.).

ExecJustVarImpl — shared implementation for the ExecJustInnerVar / ExecJustScanVar family; checks slot compatibility then reads the attribute via slot_getattr (deform-on-demand). ExecJustVarVirtImpl is the virtual-slot twin used when no FETCHSOME step was emitted.

ExecJustAssignVarImpl — shared implementation for the ExecJustAssign{Inner,Outer,Scan}Var family; copies one source attribute straight into resultslot->tts_values[resultnum] via slot_getattr, the single-column projection fast path.

ExecJustApplyFuncToCase — fused fast path for the steps_len-3 pattern EEOP_CASE_TESTVAL + strict EEOP_FUNCEXPR_STRICT{,_1,_2}; shuffles the CaseTestExpr value into place, scans args for NULL inline, and calls fn_addr directly.

ExecJustHashInnerVar / ExecJustHashOuterVar / *Strict / *WithIV — hash-join fast paths matched by the steps_len 4 and 5 cases in ExecReadyInterpretedExpr (the EEOP_HASHDATUM_* opcodes). *WithIV carries an initial-value seed (EEOP_HASHDATUM_SET_INITVAL).

CheckOpSlotCompatibility — asserts (in cassert builds) that the slot presented at runtime matches the TupleTableSlotOps the FETCHSOME step was compiled against; the cheap guard behind the “no CheckVarSlotCompatibility at runtime” optimisation.

ExecInitInterpreter — one-time setup; calls ExecInterpExpr(NULL, ...) to capture dispatch_table and populates reverse_dispatch_table for ExecEvalStepOp. Guarded by EEO_FLAG_INTERPRETER_INITIALIZED.

EEO_CASE / EEO_DISPATCH / EEO_NEXT / EEO_JUMP / EEO_OPCODE — the macro family that makes one opcode body compile to either computed-goto labels (HAVE_COMPUTED_GOTO) or switch cases. EEO_NEXT is op++; EEO_DISPATCH(); EEO_JUMP(n) is op = &steps[n]; EEO_DISPATCH().

The expression engine sits beneath every executor node. Notable call sites:

  • ExecScan / ExecScanExtended (execScan.c) — calls ExecQual (WHERE) then ExecProject (targetlist) for every scan tuple.
  • ExecProject (executor.h, inline) — calls ExecEvalExprNoReturnSwitchContext on the projection ExprState; the EEOP_ASSIGN_* steps fill resultslot->tts_values[].
  • ExecAgg (nodeAgg.c) — uses EEOP_AGG_PLAIN_TRANS_* steps compiled by ExecBuildAggTransCall inside execExpr.c.
  • ExecHashJoin / ExecHash (nodeHashjoin.c, nodeHash.c) — hash expressions compiled into ExprState arrays via ExecInitExprList.

Position hints (as of 2026-06-05, commit 273fe94)

Section titled “Position hints (as of 2026-06-05, commit 273fe94)”
SymbolFileApprox. line
ExprState structsrc/include/nodes/execnodes.h86
ExprEvalOp enumsrc/include/executor/execExpr.h66
ExprEvalStep structsrc/include/executor/execExpr.h300
EEO_FLAG_DIRECT_THREADEDsrc/include/executor/execExpr.h31
ExecInitExprsrc/backend/executor/execExpr.c143
ExecInitQualsrc/backend/executor/execExpr.c229
ExecInitChecksrc/backend/executor/execExpr.c315
ExecBuildProjectionInfosrc/backend/executor/execExpr.c370
ExecInitExprRecsrc/backend/executor/execExpr.c919
ExecInitFuncsrc/backend/executor/execExpr.c2704
ExprEvalPushStepsrc/backend/executor/execExpr.c2678
ExecCreateExprSetupStepssrc/backend/executor/execExpr.c2883
ExecReadyExprsrc/backend/executor/execExpr.c902
ExecReadyInterpretedExprsrc/backend/executor/execExprInterp.c250
ExecInterpExprsrc/backend/executor/execExprInterp.c468
ExecInterpExprStillValidsrc/backend/executor/execExprInterp.c2295
ExecJustVarImplsrc/backend/executor/execExprInterp.c2554
ExecJustInnerVarsrc/backend/executor/execExprInterp.c2571
ExecJustScanVarsrc/backend/executor/execExprInterp.c2585
ExecJustAssignVarImplsrc/backend/executor/execExprInterp.c2592
ExecJustApplyFuncToCasesrc/backend/executor/execExprInterp.c2639
ExecJustConstsrc/backend/executor/execExprInterp.c2677
ExecJustHashInnerVarsrc/backend/executor/execExprInterp.c2840
CheckOpSlotCompatibilitysrc/backend/executor/execExprInterp.c2441
ExecInitInterpretersrc/backend/executor/execExprInterp.c2936
EEO_* dispatch macrossrc/backend/executor/execExprInterp.c102
dispatch_table StaticAssertDeclsrc/backend/executor/execExprInterp.c606
EEOP_BOOL_AND_STEP casesrc/backend/executor/execExprInterp.c1055
EEOP_ASSIGN_TMP casesrc/backend/executor/execExprInterp.c879
ExecEvalExpr inlinesrc/include/executor/executor.h389
ExecProject inlinesrc/include/executor/executor.h479
ExecQual inlinesrc/include/executor/executor.h515

Verified at commit 273fe94 on branch REL_18_STABLE:

  • ExprEvalOp enum ends at EEOP_LAST (value varies but EEOP_AGG_ORDERED_TRANS_TUPLE is the last named entry before EEOP_LAST). The dispatch_table in ExecInterpExpr has a StaticAssertDecl that its length equals EEOP_LAST + 1 — confirmed present at line ~618.
  • Fast-path patterns in ExecReadyInterpretedExpr cover steps_len 2, 3, 4, and 5. The steps_len-5 case is the ExecJustHashInnerVarWithIV pattern (hash join inner var with initial value).
  • ExecJustHashOuterVarStrict is present (steps_len 4 with EEOP_HASHDATUM_FIRST_STRICT).
  • EEOP_RETURNINGEXPR opcode is present in REL_18 (added for RETURNING clauses with OLD/NEW); not in earlier branches.
  • EEO_FLAG_HAS_OLD / EEO_FLAG_HAS_NEW flags and corresponding EEOP_OLD_VAR / EEOP_NEW_VAR opcodes are present — REL_18 feature for RETURNING OLD/NEW.
  • EEOP_MERGE_SUPPORT_FUNC is present — added for MERGE support function evaluation.
  • jit_compile_expr is a stub returning false when USE_LLVM is not compiled in; when it is, it resides in jit/llvm/llvmjit_expr.c.
  • EEO_USE_COMPUTED_GOTO is defined whenever HAVE_COMPUTED_GOTO is (top of execExprInterp.c, ~line 88). The dispatch_table[] / reverse_dispatch_table[] pair and the goto *op->opcode EEO_DISPATCH body are present; portable build uses goto starteval; switch(...).
  • ExecInterpExpr(NULL, ...) returns PointerGetDatum(dispatch_table) as the ExecInitInterpreter bootstrap — confirmed; sentinel check is if (unlikely(state == NULL)).
  • ecxt_oldtuple / ecxt_newtuple are cached as oldslot/newslot locals in ExecInterpExpr — REL-18, feeding EEOP_OLD_VAR/EEOP_NEW_VAR.
  • No XLOG2 rmgr or B_DATACHECKSUMSWORKER_* BackendType found — confirms REL_18 not PG19.

Unresolved:

  • The exact boundary conditions under which ExecInterpExprStillValid can find a schema mismatch (vs. plan invalidation catching it first) are not fully traced.

Beyond PostgreSQL — Comparative Designs & Research Frontiers

Section titled “Beyond PostgreSQL — Comparative Designs & Research Frontiers”

MonetDB (and its kin, DuckDB) evaluate expressions in a vectorised model: instead of calling the evaluator once per row, the evaluator operates on a column-oriented batch of N values at once. The inner loop is a tight arithmetic loop over an array, amenable to SIMD auto-vectorisation. The PostgreSQL flat-step model is row-at-a-time; Postgres adopted a vectorised parallel execution via nodeGather / worker processes but not within the expression evaluator itself (each tuple goes through ExecInterpExpr independently).

HyPer (Neumann 2011, “Efficiently Compiling Efficient Query Plans for Modern Hardware”) introduced the produce/consume code-generation model: instead of interpreting a plan tree, the engine generates LLVM IR for the entire query, fusing operators and eliminating materialization between them. The tight loops that result have no interpreter overhead at all.

PostgreSQL’s LLVM JIT (jit/llvm/) applies a partial version of this idea: it JIT-compiles ExprState step arrays to native code when jit_above_cost, jit_inline_above_cost, or jit_optimize_above_cost thresholds are crossed. JIT does not yet fuse across plan nodes (each node’s expression is compiled independently), but it eliminates the per-step dispatch overhead for complex expressions. The Neon / cloud-native PostgreSQL forks are experimenting with per-query code generation.

CUBRID evaluates expressions recursively via a virtual-dispatch eval_with_args on QPROC_DB_VALUE_LIST node trees. This is the same pattern PostgreSQL used before PG10 — recursive tree walk with per-node dispatch. It pays more function-call overhead per node but is simpler to implement and extend. The PG10 redesign (by Andres Freund) was motivated by profiling showing the old recursive evaluator was a significant fraction of OLTP query latency.

SQL’s three-valued logic (TRUE / FALSE / NULL) requires every expression step to carry a null flag. The PostgreSQL (Datum, bool isnull) pair is the minimal representation. Some systems use a separate null bitmap over a batch (columnar null representation) or encode NULL as a sentinel value in the Datum domain (risky for arbitrary types). PostgreSQL’s per-step resvalue / resnull pair is maximally general: it works for any opaque Datum type and requires no type-specific null encoding. The specialised EEOP_FUNCEXPR_STRICT_1/2 opcodes show that even with a uniform representation, special-casing the most common null-check patterns yields measurable performance gains.

Research frontier: adaptive expression compilation

Section titled “Research frontier: adaptive expression compilation”

Recent academic work (e.g., “Adaptive Execution of Compiled Queries”, ICDE 2018) explores starting query execution in interpreted mode and transparently transitioning to JIT-compiled code for expressions that are evaluated frequently enough to warrant the compilation cost. PostgreSQL’s model currently makes the JIT decision once at plan startup based on estimated cost; adaptive per-expression promotion during execution remains an open research area for PostgreSQL.

  • src/backend/executor/execExpr.c — expression compiler
  • src/backend/executor/execExprInterp.c — interpreter and fast paths
  • src/include/executor/execExpr.hExprEvalOp, ExprEvalStep, flag bits
  • src/include/nodes/execnodes.hExprState, ExprContext
  • src/include/executor/executor.hExecEvalExpr, ExecProject, ExecQual inlines
  • src/backend/executor/README — “Expression Trees”, “Expression Initialization”, “Expression Evaluation” sections
  • knowledge/research/dbms-general/database-system-concepts.md — ch. 15 pipelining framing
  • knowledge/research/dbms-general/database-internals.md — ch. 7 query processing, expression evaluation strategies
  • knowledge/code-analysis/postgres/postgres-executor.md — Volcano iterator model, TupleTableSlot, ExprContext lifetime
  • knowledge/code-analysis/postgres/postgres-fmgr.mdFmgrInfo, FunctionCallInfo, fmgr_info resolution (forward ref)