PostgreSQL Expression Evaluation — Flat Step Array, Dispatch Loop, and Fast Paths
Contents:
- Theoretical Background
- Common DBMS Design
- PostgreSQL’s Approach
- Source Walkthrough
- Source verification (as of 2026-06-05)
- Beyond PostgreSQL — Comparative Designs & Research Frontiers
- Sources
Theoretical Background
Section titled “Theoretical Background”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:
-
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. -
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.
Common DBMS Design
Section titled “Common DBMS Design”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
switchstatement. 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
gototo 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 whenHAVE_COMPUTED_GOTOis 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/) behindjit = 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.
PostgreSQL’s Approach
Section titled “PostgreSQL’s Approach”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 opcode set (ExprEvalOp)
Section titled “The opcode set (ExprEvalOp)”The ExprEvalOp enum defines ~110 opcodes. Key groups:
| Group | Representative opcodes | Purpose |
|---|---|---|
| Slot prefetch | EEOP_INNER/OUTER/SCAN/OLD/NEW_FETCHSOME | call slot_getsomeattrs once per slot per expression |
| Var fetch | EEOP_INNER/OUTER/SCAN_VAR, EEOP_SCAN_SYSVAR | read one attribute from a pre-fetched slot |
| Var assign | EEOP_ASSIGN_INNER/OUTER/SCAN_VAR, EEOP_ASSIGN_TMP | write into resultslot->tts_values/nulls |
| Function call | EEOP_FUNCEXPR, EEOP_FUNCEXPR_STRICT, EEOP_FUNCEXPR_STRICT_1, EEOP_FUNCEXPR_STRICT_2, EEOP_FUNCEXPR_FUSAGE | call fmgr function with pre-wired fcinfo |
| Boolean | EEOP_BOOL_AND/OR_STEP_FIRST/LAST, EEOP_BOOL_NOT_STEP, EEOP_QUAL | short-circuit AND/OR/NOT; QUAL is AND with null-as-false |
| Jump | EEOP_JUMP, EEOP_JUMP_IF_NULL/NOT_NULL/NOT_TRUE | conditional skip over sub-steps |
| Constant | EEOP_CONST | load a pre-computed Datum |
| Aggregation | EEOP_AGG_PLAIN_TRANS_*, EEOP_AGG_STRICT_* | aggregate transition steps, inlined for byval/byref × strict/non-strict |
| Done | EEOP_DONE_RETURN, EEOP_DONE_NO_RETURN | terminate 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.
Qual expressions and jump patching
Section titled “Qual expressions and jump patching”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.
Projection
Section titled “Projection”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.cstatic voidExecReadyExpr(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_CONST | ExecJustConst |
3: EEOP_SCAN_FETCHSOME + EEOP_SCAN_VAR | ExecJustScanVar |
3: EEOP_INNER_FETCHSOME + EEOP_INNER_VAR | ExecJustInnerVar |
3: EEOP_SCAN_FETCHSOME + EEOP_ASSIGN_SCAN_VAR | ExecJustAssignScanVar |
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: the dispatch loop
Section titled “ExecInterpExpr: the dispatch loop”ExecInterpExpr is the interpreter. With computed gotos:
// ExecInterpExpr — src/backend/executor/execExprInterp.c (condensed)static DatumExecInterpExpr(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.
Bootstrapping the dispatch table
Section titled “Bootstrapping the dispatch table”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);#endifExecReadyInterpretedExpr 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 at runtime: assign-by-column
Section titled “Projection at runtime: assign-by-column”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.cstatic pg_attribute_always_inline DatumExecJustVarImpl(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 mermaid: ExecInterpExpr dispatch loop
Section titled “The mermaid: ExecInterpExpr dispatch loop”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
ExprContext and memory lifetime
Section titled “ExprContext and memory lifetime”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 DatumExecEvalExpr(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.
The mermaid: compilation and dispatch
Section titled “The mermaid: compilation and dispatch”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]
Source Walkthrough
Section titled “Source Walkthrough”Compilation path (execExpr.c)
Section titled “Compilation path (execExpr.c)”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.
Interpreter (execExprInterp.c)
Section titled “Interpreter (execExprInterp.c)”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().
Call sites in the executor
Section titled “Call sites in the executor”The expression engine sits beneath every executor node. Notable call sites:
ExecScan/ExecScanExtended(execScan.c) — callsExecQual(WHERE) thenExecProject(targetlist) for every scan tuple.ExecProject(executor.h, inline) — callsExecEvalExprNoReturnSwitchContexton the projectionExprState; theEEOP_ASSIGN_*steps fillresultslot->tts_values[].ExecAgg(nodeAgg.c) — usesEEOP_AGG_PLAIN_TRANS_*steps compiled byExecBuildAggTransCallinsideexecExpr.c.ExecHashJoin/ExecHash(nodeHashjoin.c,nodeHash.c) — hash expressions compiled intoExprStatearrays viaExecInitExprList.
Position hints (as of 2026-06-05, commit 273fe94)
Section titled “Position hints (as of 2026-06-05, commit 273fe94)”| Symbol | File | Approx. line |
|---|---|---|
ExprState struct | src/include/nodes/execnodes.h | 86 |
ExprEvalOp enum | src/include/executor/execExpr.h | 66 |
ExprEvalStep struct | src/include/executor/execExpr.h | 300 |
EEO_FLAG_DIRECT_THREADED | src/include/executor/execExpr.h | 31 |
ExecInitExpr | src/backend/executor/execExpr.c | 143 |
ExecInitQual | src/backend/executor/execExpr.c | 229 |
ExecInitCheck | src/backend/executor/execExpr.c | 315 |
ExecBuildProjectionInfo | src/backend/executor/execExpr.c | 370 |
ExecInitExprRec | src/backend/executor/execExpr.c | 919 |
ExecInitFunc | src/backend/executor/execExpr.c | 2704 |
ExprEvalPushStep | src/backend/executor/execExpr.c | 2678 |
ExecCreateExprSetupSteps | src/backend/executor/execExpr.c | 2883 |
ExecReadyExpr | src/backend/executor/execExpr.c | 902 |
ExecReadyInterpretedExpr | src/backend/executor/execExprInterp.c | 250 |
ExecInterpExpr | src/backend/executor/execExprInterp.c | 468 |
ExecInterpExprStillValid | src/backend/executor/execExprInterp.c | 2295 |
ExecJustVarImpl | src/backend/executor/execExprInterp.c | 2554 |
ExecJustInnerVar | src/backend/executor/execExprInterp.c | 2571 |
ExecJustScanVar | src/backend/executor/execExprInterp.c | 2585 |
ExecJustAssignVarImpl | src/backend/executor/execExprInterp.c | 2592 |
ExecJustApplyFuncToCase | src/backend/executor/execExprInterp.c | 2639 |
ExecJustConst | src/backend/executor/execExprInterp.c | 2677 |
ExecJustHashInnerVar | src/backend/executor/execExprInterp.c | 2840 |
CheckOpSlotCompatibility | src/backend/executor/execExprInterp.c | 2441 |
ExecInitInterpreter | src/backend/executor/execExprInterp.c | 2936 |
EEO_* dispatch macros | src/backend/executor/execExprInterp.c | 102 |
dispatch_table StaticAssertDecl | src/backend/executor/execExprInterp.c | 606 |
EEOP_BOOL_AND_STEP case | src/backend/executor/execExprInterp.c | 1055 |
EEOP_ASSIGN_TMP case | src/backend/executor/execExprInterp.c | 879 |
ExecEvalExpr inline | src/include/executor/executor.h | 389 |
ExecProject inline | src/include/executor/executor.h | 479 |
ExecQual inline | src/include/executor/executor.h | 515 |
Source verification (as of 2026-06-05)
Section titled “Source verification (as of 2026-06-05)”Verified at commit 273fe94 on branch REL_18_STABLE:
ExprEvalOpenum ends atEEOP_LAST(value varies butEEOP_AGG_ORDERED_TRANS_TUPLEis the last named entry beforeEEOP_LAST). Thedispatch_tableinExecInterpExprhas aStaticAssertDeclthat its length equalsEEOP_LAST + 1— confirmed present at line ~618.- Fast-path patterns in
ExecReadyInterpretedExprcover steps_len 2, 3, 4, and 5. The steps_len-5 case is theExecJustHashInnerVarWithIVpattern (hash join inner var with initial value). ExecJustHashOuterVarStrictis present (steps_len 4 withEEOP_HASHDATUM_FIRST_STRICT).EEOP_RETURNINGEXPRopcode is present in REL_18 (added for RETURNING clauses with OLD/NEW); not in earlier branches.EEO_FLAG_HAS_OLD/EEO_FLAG_HAS_NEWflags and correspondingEEOP_OLD_VAR/EEOP_NEW_VARopcodes are present — REL_18 feature for RETURNING OLD/NEW.EEOP_MERGE_SUPPORT_FUNCis present — added for MERGE support function evaluation.jit_compile_expris a stub returning false whenUSE_LLVMis not compiled in; when it is, it resides injit/llvm/llvmjit_expr.c.EEO_USE_COMPUTED_GOTOis defined wheneverHAVE_COMPUTED_GOTOis (top ofexecExprInterp.c, ~line 88). Thedispatch_table[]/reverse_dispatch_table[]pair and thegoto *op->opcodeEEO_DISPATCHbody are present; portable build usesgoto starteval; switch(...).ExecInterpExpr(NULL, ...)returnsPointerGetDatum(dispatch_table)as theExecInitInterpreterbootstrap — confirmed; sentinel check isif (unlikely(state == NULL)).ecxt_oldtuple/ecxt_newtupleare cached asoldslot/newslotlocals inExecInterpExpr— REL-18, feedingEEOP_OLD_VAR/EEOP_NEW_VAR.- No
XLOG2rmgr orB_DATACHECKSUMSWORKER_*BackendType found — confirms REL_18 not PG19.
Unresolved:
- The exact boundary conditions under which
ExecInterpExprStillValidcan 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 vectorised evaluation
Section titled “MonetDB and vectorised evaluation”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 / Umbra and code generation
Section titled “HyPer / Umbra and code generation”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 and pre-PG10 recursive evaluation
Section titled “CUBRID and pre-PG10 recursive evaluation”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.
Null handling and three-valued logic
Section titled “Null handling and three-valued logic”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.
Sources
Section titled “Sources”src/backend/executor/execExpr.c— expression compilersrc/backend/executor/execExprInterp.c— interpreter and fast pathssrc/include/executor/execExpr.h—ExprEvalOp,ExprEvalStep, flag bitssrc/include/nodes/execnodes.h—ExprState,ExprContextsrc/include/executor/executor.h—ExecEvalExpr,ExecProject,ExecQualinlinessrc/backend/executor/README— “Expression Trees”, “Expression Initialization”, “Expression Evaluation” sectionsknowledge/research/dbms-general/database-system-concepts.md— ch. 15 pipelining framingknowledge/research/dbms-general/database-internals.md— ch. 7 query processing, expression evaluation strategiesknowledge/code-analysis/postgres/postgres-executor.md— Volcano iterator model, TupleTableSlot, ExprContext lifetimeknowledge/code-analysis/postgres/postgres-fmgr.md—FmgrInfo,FunctionCallInfo,fmgr_inforesolution (forward ref)