1 The execution model β running a plan tree
Imagine a line of friends passing buckets of water from a well to a fire. The person at the fire shouts "next bucket!" The friend behind them shouts "next bucket!" to the friend behind them, all the way back to the well. The well fills one bucket, it gets passed up the line, and the fire gets watered. Nobody fills a thousand buckets and stacks them in a pile β each bucket is fetched only when someone above asks for it. A database runs your query exactly like this: each step asks the step below it for one row at a time.
Recall from Session 8 that the optimiser hands the executor a query plan: a tree where each node is an operator (a small piece of machinery like "scan this table", "filter these rows", "join these two inputs"). Leaves read data; the root produces the final answer. Execution is the act of actually running that tree to produce rows.
The Volcano / iterator model
The dominant way to run a plan tree is the Volcano model (also called the iterator model or pipeline model), described by Goetz Graefe in the early 1990s. The idea is beautifully uniform: every operator exposes the same three methods, no matter what it does.
| Method | What it does | Bucket-line analogy |
|---|---|---|
open() | Get ready: set up state, open files, tell children to open() too. | "Everyone, get in line." |
next() | Produce and return the next single tuple (or "no more rows"). | "Pass me one bucket." |
close() | Clean up: free memory, close files, tell children to close(). | "We're done, go home." |
A tuple is just one row. Because every operator speaks this same little language, you can plug any operator on top of any other β a filter on top of a scan, a join on top of two filters β and it all just works. This is what Graefe meant by extensible: adding a new operator never breaks the others.
The root calls next(). That operator calls next()
on its child to get the row it needs, which calls next() on its
child, and so on down to the leaf. One tuple flows up the tree per call. This is
pull-based execution: data is pulled from the top, not
pushed from the bottom.
Pull-based, one tuple at a time
The opposite of pulling would be materialisation: each operator fully computes its entire output, stores it in a temporary table, and hands the whole thing up. That wastes huge amounts of memory for big tables. The Volcano model instead pipelines: a row is produced, flows all the way to the top, gets returned to the user, and only then does the next row get pulled. Memory stays tiny, and the very first row can come back almost instantly.
The request travels down (the next() calls); the tuple travels back
up. Repeat until the scan says "no more rows."
Here is the skeleton every operator follows. This particular one is a generic template β real operators (scan, filter, join) just fill in the bodies.
// Every operator implements this same interface. interface Operator { void open(); // prepare state, open children Tuple next(); // return next tuple, or NULL when exhausted void close(); // release resources, close children } // The driver loop the database runs at the very top: function runQuery(rootOperator): rootOperator.open() while (tuple = rootOperator.next()) is not NULL: emitToClient(tuple) // send this row to the user rootOperator.close()
Notice the whole query is driven by one tiny loop pulling tuples off the root until it returns
NULL. Everything else is operators calling next() on their children.
Graefe's research system was literally named Volcano. People often draw the plan tree with the leaves at the bottom and tuples "erupting" up to the root β hence the name. It also pioneered parallel execution, but we'll keep to the single-threaded picture in this session.
The iterator model is simple and memory-light, but calling next() millions
of times β once per row β adds up. Each call has overhead. Modern systems often process a
batch of tuples per next() (the vectorised
model) or compile the plan to machine code to avoid this. We mention these as the modern
evolution; the classic one-tuple Volcano model is still the clearest way to understand execution.
open, next, close.
The root pulls one tuple at a time; each next() cascades down to the leaves
and a single row flows back up. This pull-based, pipelined design keeps memory tiny
and returns the first row fast.
2 Scan operators β getting rows off disk
You want to find your friend Sam in a huge phone book. One way: start at page 1 and read every single name until you hit Sam β slow, but it always works. The other way: the book is sorted alphabetically, so you flip straight to "S" and find Sam in seconds. The catch: that shortcut only helps if the book is sorted the right way. A database has the same two choices for reading a table: read every row, or use a sorted shortcut called an index.
Scan operators are the leaves of the plan tree β they're where actual data enters the pipeline. The two you'll meet constantly are the sequential scan and the index scan.
SeqScan β read the whole table
A SeqScan (sequential / full-table scan) reads every page of the table from start to finish and emits each row. Recall from earlier sessions that a table is stored as a set of fixed-size pages (blocks) on disk, each holding many rows. SeqScan walks them all.
class SeqScan implements Operator: function open(): self.pageCursor = firstPageOf(self.table) self.rowCursor = 0 function next(): while self.pageCursor is not NULL: if self.rowCursor < rowsIn(self.pageCursor): tuple = readRow(self.pageCursor, self.rowCursor) self.rowCursor += 1 return tuple // page exhausted, move to the next one self.pageCursor = nextPage(self.pageCursor) self.rowCursor = 0 return NULL // no more pages, table fully scanned function close(): releasePages(self.table)
IndexScan β use a sorted shortcut
An IndexScan uses an index (typically a B+-tree, covered in our indexing sessions) to jump straight to the rows that match a condition, without reading the whole table. The index is a separate, sorted structure mapping column values to the locations of matching rows.
For WHERE age BETWEEN 30 AND 40 on an index over age:
class IndexScan implements Operator: function open(): // descend the B+-tree to the first key >= low bound self.cursor = index.seek(self.lowKey) // e.g. age = 30 function next(): if self.cursor is atEnd or self.cursor.key > self.highKey: return NULL // passed age = 40, stop rowId = self.cursor.value // pointer to the actual row tuple = fetchRow(rowId) // follow pointer to the heap self.cursor = self.cursor.advance() // next sorted entry return tuple function close(): index.releaseCursor(self.cursor)
The leaf entries of a B+-tree are linked in sorted order, so once we've found age =
30 we just walk forward, fetching each matching row, until a key exceeds 40.
When is each chosen? Cost intuition
The optimiser (Session 8) picks based on selectivity β what fraction of the table matches. The key trade-off is sequential vs random I/O:
- SeqScan reads pages in order β fast, sequential disk reads β but reads everything. Cost β number of pages in the table.
- IndexScan reads only matching rows, but each matching row may require a random jump to a different page (slow), plus the cost of walking the index.
| Situation | Better choice | Why |
|---|---|---|
| Query returns most of the table (low selectivity, e.g. > ~10β20%) | SeqScan | You'd touch most pages anyway; sequential reads beat thousands of random jumps. |
Query returns a tiny fraction (high selectivity, e.g. id = 42) | IndexScan | Jump straight to a few rows instead of reading millions. |
| No useful index exists on the predicate column | SeqScan | There's no shortcut to use. |
| Need rows in sorted order & index matches that order | IndexScan | Index already yields rows sorted β avoids a separate sort (Session 10). |
Beginners assume "index = always faster." Not so. If a query matches 80% of rows, an IndexScan would do ~hundreds of thousands of random page fetches (one per matching row, jumping all over the disk), while a SeqScan reads the whole table in efficient sequential order. Above a certain selectivity threshold, the full scan wins β which is exactly why the cost-based optimiser estimates row counts before deciding.
3 Filter & Projection β the pipeline's gatekeepers
Picture a conveyor belt of lunchboxes. A filter is a worker who only lets through the lunchboxes that have a sandwich inside, tossing the rest. A projection is the next worker who opens each allowed lunchbox and keeps only the sandwich and the apple, throwing away everything else. One decides which boxes pass; the other decides what parts of each box you keep. Both work on one lunchbox at a time as it rolls by β they never stop the belt.
Filter and Projection map directly onto two pieces of SQL you already know: the
WHERE clause and the SELECT list.
Filter β applying the WHERE predicate
A Filter operator (also called Selection, the Ο in relational algebra) sits on top of a child operator. For each tuple the child gives it, it evaluates a boolean predicate (the WHERE condition). If the predicate is true, the tuple passes up; if false, it's dropped and the filter immediately asks its child for another.
For WHERE salary > 50000:
class Filter implements Operator: function open(): self.child.open() function next(): while (tuple = self.child.next()) is not NULL: if evaluate(self.predicate, tuple) is TRUE: return tuple // passed the test // else: drop it and loop to fetch the next child tuple return NULL // child exhausted function close(): self.child.close()
The while loop is the whole trick: a filter may pull many child tuples
before it finds one to return. It's still one tuple out per next()
to the operator above it β the pipeline shape is preserved.
Projection β choosing and computing columns
A Projection operator (the Ο in relational algebra) reshapes each tuple: it keeps only the requested columns and can compute new ones. Crucially, a filter changes how many rows flow; a projection changes what each row looks like but never adds or removes rows.
For SELECT name, salary * 12 AS annual:
class Projection implements Operator: function open(): self.child.open() function next(): tuple = self.child.next() if tuple is NULL: return NULL return { name: tuple.name, annual: evaluate(tuple.salary * 12, tuple) // computed column } function close(): self.child.close()
Expression evaluation
Both operators rely on expression evaluation: turning something like
salary > 50000 or salary * 12 into a value for a
given tuple. Internally an expression is a small tree β and yes, it's evaluated by the same recursive
pattern as the plan tree.
The predicate salary > 50000 parses to:
>
/ \
salary 50000
function evaluate(node, tuple):
if node is Constant: return node.value // 50000
if node is Column: return tuple[node.name] // look up salary in the row
if node is Operator: // e.g. >, *, AND
left = evaluate(node.left, tuple)
right = evaluate(node.right, tuple)
return apply(node.op, left, right) // 60000 > 50000 -> TRUE
Filter and Projection are pipelined (sometimes called on-the-fly) operators: they hold no big intermediate table, just pass tuples through. The optimiser likes to push filters as low as possible in the tree (called predicate pushdown) so rows are discarded early β there's no point carrying a row up through three joins only to throw it away at the top.
4 Nested loop join β the simplest join
You have a stack of name cards and a stack of phone-number cards, and you want to pair up the ones that match. The most obvious way: pick up the first name card, then flip through the entire phone-number stack looking for matches; then pick up the second name card and flip through the whole phone stack again; and so on. It's not clever, but it always works. That "for each card, go through the whole other stack" idea is a nested loop join.
A join combines rows from two tables based on a matching condition (e.g.
Employees.dept_id = Departments.id). The nested loop
join is the simplest algorithm and the foundation for the rest. There are three flavours.
Naive (tuple-at-a-time) nested loop join
For every tuple in the outer table, scan every tuple in the inner table and emit the pairs that satisfy the join condition.
class NestedLoopJoin implements Operator: function open(): self.outer.open() self.inner.open() self.outerTuple = self.outer.next() // grab first outer row function next(): while self.outerTuple is not NULL: while (innerTuple = self.inner.next()) is not NULL: if joinMatches(self.outerTuple, innerTuple): return combine(self.outerTuple, innerTuple) // inner exhausted: advance outer, rewind inner self.outerTuple = self.outer.next() self.inner.close(); self.inner.open() // restart inner scan return NULL function close(): self.outer.close(); self.inner.close()
Note the inner side is re-scanned from scratch for every outer row. That's the expensive part.
If the outer table has M rows and the inner has N
rows, this compares roughly M Γ N pairs β O(MΒ·N)
time. Worse, if we re-read the inner table from disk every time, the I/O cost is brutal.
Block nested loop join
The big waste above is re-reading the inner table once per outer row. The block nested loop join fixes this by reading the outer table a block of pages at a time: load as many outer rows as fit in memory, then scan the inner table once for that whole block. This cuts inner re-scans from "once per outer row" to "once per outer block" β a massive I/O reduction. (Same O(MΒ·N) comparisons, but far fewer disk reads.)
Index nested loop join
If there's an index on the inner table's join column, we don't scan the inner table at all. For each outer row, we do an IndexScan (Topic 2) to jump straight to matching inner rows. This turns the inner "loop" into a fast lookup.
class IndexNestedLoopJoin implements Operator: function next(): while TRUE: if self.innerCursor has moreMatches: inner = self.innerCursor.next() return combine(self.outerTuple, inner) self.outerTuple = self.outer.next() if self.outerTuple is NULL: return NULL // probe the index instead of scanning the whole inner table self.innerCursor = innerIndex.lookup(self.outerTuple.joinKey)
Cost becomes roughly M Γ (cost of one index lookup) β often
O(MΒ·log N) instead of O(MΒ·N). This is the best nested loop variant when a
suitable index exists.
| Variant | Inner side per outer row | Rough cost | Good when⦠|
|---|---|---|---|
| Naive | Full re-scan, one row at a time | O(MΒ·N), heavy I/O | Both tables tiny; teaching example. |
| Block | Inner scanned once per outer block | O(MΒ·N) compares, far less I/O | No index; one table fits in a few pages. |
| Index | Index lookup, no scan | O(MΒ·log N) | Index exists on inner join column; outer is small. |
Join Employees (outer) to Departments (inner) on
dept_id = id:
| Employees (outer) | Departments (inner) |
|---|---|
| (Ann, dept 1) | (1, Sales) |
| (Bob, dept 2) | (2, Eng) |
| (Cy, dept 1) | (3, HR) |
Naive nested loop, step by step (M=3 outer rows, N=3 inner rows β 9 comparisons):
- Ann (dept 1) vs (1 Sales) β match! emit
(Ann, Sales); vs (2 Eng) β no; vs (3 HR) β no. - Bob (dept 2) vs (1 Sales) β no; vs (2 Eng) β match! emit
(Bob, Eng); vs (3 HR) β no. - Cy (dept 1) vs (1 Sales) β match! emit
(Cy, Sales); vs (2 Eng) β no; vs (3 HR) β no.
Result: (Ann, Sales), (Bob, Eng), (Cy, Sales). With an index on
Departments.id, each employee would jump straight to its one matching
department β 3 quick lookups instead of 9 comparisons.
Good: when one table is small, or an index lets you avoid scanning the inner side (index nested loop). It also streams results immediately and uses little memory. Bad: joining two large tables with no helpful index β O(MΒ·N) explodes (a million Γ a million is a trillion comparisons). For that case we use smarter algorithms β hash join and sort-merge join β which we cover in Session 10.
β Putting it all together
You now know how a database turns a plan into running machinery. Here's the one-paragraph story connecting all four topics:
The optimiser's plan tree is run by the Volcano / iterator model:
every operator exposes open, next,
close, and the root pulls one tuple at a time down through
the tree. At the leaves, scan operators feed rows in β a SeqScan
reads the whole table, an IndexScan uses a sorted B+-tree to grab only matching rows,
and the optimiser chooses between them by estimated selectivity. As tuples flow up, a
Filter drops rows that fail the WHERE predicate and a Projection
keeps/computes columns β both pipelined, both driven by recursive expression evaluation.
To combine two tables, the simplest tool is the nested loop join, whose
naive, block, and index variants trade off comparisons and I/O β and when
tables get large, we'll reach for hash and sort-merge joins next time.
Quick self-check
What three methods does every operator in the Volcano model expose?
open(), next(), and
close(). The root repeatedly calls next(), which
cascades down the tree, and one tuple flows back up per call.
Why is execution called "pull-based"?
Because data is pulled from the top: the root asks its child for the next tuple, which asks its child, down to the leaf scan. Nothing is computed until something above requests it, which keeps memory small and returns the first row quickly (pipelining).
Your query matches 90% of a large table. SeqScan or IndexScan, and why?
SeqScan. An IndexScan would do a random page fetch for almost every row; reading the whole table sequentially is far cheaper. Indexes win for high selectivity (few matching rows), not when you need most of the table.
How do Filter and Projection differ in their effect on the tuple stream?
Filter changes how many rows pass (drops those failing the predicate); Projection changes what each row looks like (keeps/computes columns) but never changes the row count.
Why does a naive nested loop join cost O(MΒ·N), and how does an index nested loop join help?
For each of M outer rows it scans all N inner rows β MΒ·N comparisons. With an index on the inner join column, each outer row does a fast index lookup instead of a full scan, giving roughly O(MΒ·log N).
What's the point of pushing a filter low in the plan tree (predicate pushdown)?
To discard rows as early as possible, so fewer rows are carried up through expensive operators like joins. There's no point processing a row through several operators only to throw it away at the top.
π References & Further Reading
Class material
- π Original course notes / handout (source sheet) β open the shared class material for this session.
- DBMS Session 9 β Query Execution (your course handout for this session).
Papers, docs & deep dives
- CMU 15-445 β Database Systems (course site) β the gold-standard free course; its query execution and join lectures cover exactly this material with slides and recorded videos.
- Graefe β "Volcano: An Extensible and Parallel Query Evaluation System" (IEEE TKDE, 1994) β the original paper that defined the open/next/close iterator model we used all session.
- Silberschatz, Korth & Sudarshan β Database System Concepts β the standard textbook; the "Query Processing" chapter walks through scans, selection, projection, and join algorithms in depth.
- PostgreSQL docs β Using EXPLAIN β see real plan trees with Seq Scan, Index Scan, and Nested Loop nodes in a production database, with cost and row estimates.
- CMU 15-445 lecture notes (Fall 2023) β concise written notes on the processing model, access methods, and join algorithms to complement the videos.