The idea: every node pulls rows from its child
The planner hands the executor a plan tree, the same tree EXPLAIN prints. The executor does not run the nodes one after another. Each node is an iterator with one entry point, ExecProcNode(): "give me your next row". A node answers with one row, or with NULL when it has no more. To answer, it calls its own children as often as it needs to. This is the Volcano (or iterator) model, named after Goetz Graefe's 1994 Volcano system, and most row-store databases execute queries this way.
The client (the portal, driven by ExecutorRun or a cursor's FETCH) asks the top node for rows. Nothing below runs unless someone above asked. Between two calls the whole tree just sits there, holding its place: the scan remembers which row it read last, the sort remembers which row it handed out last.
One call, one row
In the animation a node turns orange while it runs and blue while it waits for its child. A Seq Scan reads heap rows until one passes its Filter; rows that fail are skipped inside the same call and counted as Rows Removed by Filter. The row it found flies up to the parent, and the parent does whatever it does with it. Demo: one FETCH at a time asks the top node for one row per press, as FETCH 1 FROM c does on a cursor.
BEGIN; DECLARE c CURSOR FOR SELECT * FROM orders WHERE amount > 50 ORDER BY amount LIMIT 2; FETCH 1 FROM c; -- one ExecProcNode call on the top node FETCH 1 FROM c;
LIMIT stops early
Limit passes rows on and counts them. Once it has returned n rows, the next call gets NULL at once: Limit does not call its child again. So for WHERE amount > 50 LIMIT 2 the Seq Scan reads only 4 of the 8 rows and never touches the rest (Demo: LIMIT stops the scan early). On a table of millions of rows, that is the difference between reading one page and reading the whole table. The planner knows this: it prefers plans with a low startup cost (the first number in cost=0.00..1.10) under a LIMIT.
Streaming nodes and blocking nodes
A streaming node can return a row as soon as it gets one: Seq Scan, Index Scan, Limit, Nested Loop, the probe side of Hash Join, Merge Join. A blocking node cannot: Sort does not know which row is smallest until it has seen them all, and Hash must hold every row of the inner side before the join can probe it. On its first call such a node pulls its whole input into work_mem, and only then returns its first row (Demo: Sort reads everything first).
| Node | Kind | Before its first row it must… |
|---|---|---|
| Seq Scan, Index Scan | streaming | find one matching row |
| Limit, Result, Nested Loop | streaming | get one row from below |
| Hash Join | blocking on the inner side | build the whole hash table, then one outer row |
| Sort | blocking | read and sort its whole input |
| HashAggregate | blocking | read its whole input (any group may get more rows) |
Hash Join shows both at once. Its first call runs MultiExecProcNode(Hash), which pulls all of customers into the hash table and returns the table itself, not a row. After that it streams: each call pulls one outer row from orders, probes one bucket and returns the joined row. With LIMIT 2 it reads all 3 customers but only 2 orders (Demo: Hash Join builds, then streams).
Reading EXPLAIN ANALYZE with the pull model in mind
Limit (actual rows=2 loops=1)
-> Sort (actual rows=2 loops=1)
Sort Key: amount
Sort Method: quicksort
-> Seq Scan on orders (actual rows=4 loops=1)
Filter: (amount > 50)
Rows Removed by Filter: 4
actual rows is the number of rows a node returned to its parent, not the number it could have returned. Here Sort sorted 4 rows but shows rows=2, because Limit asked for only two. A node that was never called prints (never executed), for example the inner side of a join whose outer side was empty. loops counts how often the node was started from the beginning; the inner side of a Nested Loop is rescanned once per outer row. For a LIMIT 2 over this many rows the real Sort would switch to top-N heapsort and keep only 2 rows; with 4 input rows it stays below that threshold (twice the limit) and uses quicksort.
The join, GROUP BY and sort methods themselves are on How PostgreSQL Executes JOIN, GROUP BY and ORDER BY; how the planner chooses the tree is on How a Query Planner Picks a Plan. See also How PostgreSQL Runs a Query for the whole path from parser to heap, and PostgreSQL parallel query, where a Gather node pulls rows from worker processes.
An Append node over table partitions can skip some of them at run time; see PostgreSQL Partitioning and Partition Pruning.
What the page leaves out
Real nodes return a tuple table slot, not a copied row, and expressions (the Filter, the join condition) are compiled to a small interpreter or JIT code. The real Hash Join often fetches the first outer row before it builds the hash table, so that an empty outer side costs nothing. Also left out: MVCC visibility checks during the scan, shared buffers, batches when the hash table exceeds work_mem, external sorts on disk, rescans and parameters of Nested Loop, asynchronous Append, and how the portal sends rows to the client over the wire protocol.
See where this page fits on the PostgreSQL Internals mind map.