skip to content

Materializing an intermediate result costs memory and delays the first row, so why would a query optimizer deliberately insert a node that buffers an intermediate result instead of recomputing or re-scanning it?

level: seniorimportance: should knowfreq 30%

answer

  1. buffer once, replay many
  2. spool above a nested-loop inner input
  3. DAG plans: one producer, many consumers
  4. Halloween problem: freeze rows before modifying
  5. cost = blocking + memory + possible spill

basics

~20 s

Because the intermediate is consumed more than once, or must be frozen. Buffering it turns repeated re-execution of a subtree into repeated cheap reads, lets one result feed several consumers, stabilizes a result that would otherwise change while the statement modifies the same rows, and shields an expensive or non-deterministic subplan from repeated evaluation.

solid answer

~1 min

An optimizer trades the cost of buffering against the cost of *re-producing* rows. It pays off when: - **Repeated consumption.** An inner subtree re-scanned once per outer row is executed N times; buffering it once turns N executions of a subtree into N cheap reads of a small temporary result. This is the classic spool above a nested-loop inner input. - **Multiple consumers.** In plans where one intermediate feeds several branches (shared subexpressions, some grouping-set and window plans), materializing once beats computing it per branch. - **Correctness under self-modification.** A statement reading and writing the same rows can otherwise see its own changes; materializing separates the read phase from the write phase (the classic Halloween problem). - **Isolating expensive or volatile work.** Buffering an expensive function or a remote/foreign scan avoids evaluating it repeatedly, and pins a result that must be evaluated exactly once. - **Cursor and rewind requirements.** A result that must be revisited or scrolled needs storage; recomputation is either impossible or wasteful. The cost is real — memory, potential spill, and a later first row — so the optimizer only chooses it when the estimated repetition count or the sharing justifies it, and a bad cardinality estimate can make that call badly wrong.

code

text · 8 lines
text
Nested Loop  (outer rows = 5,000)
  -> Seq Scan on drivers            (outer)
  -> Materialize                    (buffered once, replayed 5,000x)
       -> Seq Scan on regions
            Filter: active

Without Materialize the inner Seq Scan runs 5,000 times.
With it, it runs once and 4,999 replays read the buffer.

go deeper

for a junior

Know that a plan can deliberately buffer an intermediate result so it can be read more than once instead of being recomputed.

for a middle

Explain the nested-loop spool trade — execute the inner subtree once instead of once per outer row — and that buffering costs memory and delays the first row.

for a senior

Add multi-consumer DAG plans, correctness-driven materialization for self-modifying statements, lazy spools, and how to judge from estimated-versus-actual whether the trade was right.

for a principal

Frame it as the optimizer's reuse-versus-recompute decision under uncertain cardinalities, and discuss when to force or avoid it at the schema, index or query-shape level.

## The trade being made Pipelining is the default for a good reason: no intermediate storage, bounded memory, early first rows. A deliberate materialize (also called spool, temp result, or buffer) node inverts that: it consumes a subtree once, stores the rows, and serves them from storage afterwards. The optimizer inserts it only when *reproducing* rows would cost more than *storing* them. ## Reason 1: the intermediate is read many times The dominant case is a nested-loop join whose inner input is not a cheap index lookup. Semantically, the inner subtree is executed once per outer row. If the inner is a filtered scan producing a small result, executing it a thousand times means a thousand scans. A spool executes it once, keeps the rows, and replays them for each subsequent outer row. The saving scales with the outer cardinality; the cost is one buffered copy. A related case is a recursive or iterative construct where the same working set is revisited, and correlated subqueries whose uncorrelated part can be computed once. ## Reason 2: one producer, several consumers Some plans are DAGs rather than trees: a common subexpression referenced twice, a grouping-set query that aggregates the same input at several granularities, several window functions over the same partitioning. Without materialization the engine either recomputes the shared input per consumer or duplicates the subtree. Materializing once and feeding all consumers converts N computations into one computation plus N reads. This is also the mechanism behind optimizer decisions to materialize a common table expression instead of inlining it into each reference. ## Reason 3: correctness — separating read from write When a statement modifies rows it is also reading — updating a column that is itself part of the access path, for example — a fully pipelined plan can re-read a row it already changed and process it again, potentially forever. This is the **Halloween problem**. The standard remedy is to break the pipeline: materialize the set of rows to be modified (or their identifiers) first, then apply modifications from that frozen set. Here materialization is not an optimization at all; it is required for correct semantics. Recognizing that some breakers exist for correctness rather than performance is a strong answer in interviews. ## Reason 4: pinning expensive, remote or volatile evaluation If a subplan calls an expensive user function, reads a foreign or remote table, or must be evaluated exactly once for semantic reasons, materializing bounds the number of evaluations. It also stabilizes the result: repeated evaluation of something non-deterministic would otherwise produce inconsistent rows across consumers. ## Reason 5: rewind, scroll and reuse A scrollable cursor that can move backwards, or an operator that must restart its input from the beginning, needs the rows to still exist. Some engines can restart a subtree cheaply (re-scan an index), and prefer that; where restart is expensive or impossible (a non-repeatable source), the engine buffers. ## What it costs - **Memory, then temp storage.** Materialization is a blocking step: the buffer must be complete before it can be read repeatedly. If it exceeds its grant it spills, and now the "cheap replay" is disk reads. - **Latency.** The first output row of anything above it waits for the whole subtree. - **Wasted work under early termination.** If the consumer stops after a few rows, buffering everything was pure loss. Optimizers sometimes use a *lazy* spool that fills only as far as consumption requires, precisely to avoid this. - **Estimation sensitivity.** The decision hinges on the estimated number of re-reads and the estimated size of the buffer. Underestimate the size and you spill; overestimate the repetitions and you materialize for nothing. ## How to reason about it when tuning Seeing a materialize/spool node is not automatically bad. Ask: how many times is this consumed, and how big is it? A small buffer replayed thousands of times is an excellent trade. A huge buffer read once is almost always a symptom — either a misestimate, or a plan shape that should have used a hash join (which does its own build-side buffering more efficiently) or an index that removes the repetition. Conversely, if you see the same expensive subtree executed once per outer row with no buffering, the estimate for the outer side is probably far too low.

  • When is a materialize node a warning sign rather than a good trade?
    When the buffer is large and read only once or twice, or when it spills to temporary storage. That usually means the optimizer expected a small intermediate and a high repetition count and got neither. Look at estimated versus actual rows at that node; the fix is often better statistics, an index that makes the inner side a cheap lookup, or a hash join whose build side buffers more efficiently.
  • What is the Halloween problem and how does materialization address it?
    It occurs when a statement modifies rows through the same access path it is reading, so an updated row can be re-encountered and updated again, possibly repeatedly. Breaking the pipeline fixes it: the engine materializes the set of qualifying rows or their identifiers first, then applies the modifications from that frozen set, so no row can be revisited. This materialization is required for correctness, not for speed.

Photocopying a reference page once instead of walking to the library every time you need it — worth it only if you will consult it repeatedly, and only if the page fits on your desk.

saying these in an interview costs you the question

  • Treating every materialize or spool node in a plan as a defect to be removed.
  • Not knowing that some breakers exist for correctness (self-modifying statements), not performance.
  • Assuming materialization is free once the rows fit in memory, ignoring the delayed first row and the spill risk.
  • Confusing a deliberate materialize node with the hash-join build side, which buffers as part of its own algorithm.
  • Saying materialization always reduces total work, ignoring the case where the consumer stops early and the buffering was wasted.

context