πŸ“š Study Notes / Home / DBMS / Session 9
Session 09 Β· Query Execution

How a database actually runs your query

In Session 8 we watched your SQL get parsed and turned into a plan β€” a tree of operations. But a plan is just a recipe. Today we learn how the database cooks: how it turns that plan tree into machinery that streams real rows out of disk, one tuple at a time. We assume you've studied none of this before. Every topic starts with a tiny "explain like I'm 5" story, then we build up the real detail with pseudocode and worked examples. Take it slow β€” by the end you'll understand exactly what's happening between "press enter" and "here are your rows."

⏱ 18 min readπŸ“– 4 topics

1 The execution model β€” running a plan tree


Explain like I'm 5

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.

MethodWhat it doesBucket-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 big idea

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.

πŸ™‹
User / root
calls next()
β†’
πŸ”—
Join
needs a row, asks child
β†’
πŸ”
Filter
needs a row, asks child
β†’
πŸ“„
Scan (leaf)
reads 1 tuple from disk

The request travels down (the next() calls); the tuple travels back up. Repeat until the scan says "no more rows."

Worked example: a generic iterator

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.

Where the name "Volcano" comes from

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.

Trade-off: one function call per tuple

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.

Recap The optimiser's plan tree is run by the Volcano / iterator model: every operator exposes 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


Explain like I'm 5

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.

SeqScan as an iterator
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.

IndexScan for a range, as an iterator

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.
SituationBetter choiceWhy
Query returns most of the table (low selectivity, e.g. > ~10–20%)SeqScanYou'd touch most pages anyway; sequential reads beat thousands of random jumps.
Query returns a tiny fraction (high selectivity, e.g. id = 42)IndexScanJump straight to a few rows instead of reading millions.
No useful index exists on the predicate columnSeqScanThere's no shortcut to use.
Need rows in sorted order & index matches that orderIndexScanIndex already yields rows sorted β€” avoids a separate sort (Session 10).
Why an index can be slower

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.

Recap Scans are the leaves that feed rows into the pipeline. SeqScan reads every page (great when you need most of the table or have no index). IndexScan uses a sorted B+-tree to fetch only matching rows (great for high-selectivity lookups), trading sequential I/O for random I/O. The optimiser chooses based on estimated selectivity and cost.

3 Filter & Projection β€” the pipeline's gatekeepers


Explain like I'm 5

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.

Filter as an iterator

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.

Projection as an iterator

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.

Evaluating an expression 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
Pipelined, so order matters

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.

Recap Filter (selection / Οƒ) drops rows that fail the WHERE predicate, looping to fetch the next child tuple until one passes. Projection (Ο€) keeps and computes columns per the SELECT list without changing the row count. Both are pipelined and both lean on expression evaluation, which recursively walks a little expression tree against each tuple. Filters get pushed down so rows die early.

4 Nested loop join β€” the simplest join


Explain like I'm 5

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.

Naive nested loop, as an iterator
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.

Index nested loop, as an iterator
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.

VariantInner side per outer rowRough costGood when…
NaiveFull re-scan, one row at a timeO(MΒ·N), heavy I/OBoth tables tiny; teaching example.
BlockInner scanned once per outer blockO(MΒ·N) compares, far less I/ONo index; one table fits in a few pages.
IndexIndex lookup, no scanO(MΒ·log N)Index exists on inner join column; outer is small.
Worked example: two small tables

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.

When nested loop is good β€” and bad

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.

Recap The nested loop join pairs rows by, for each outer tuple, looking through the inner input. Naive re-scans the whole inner table per row (O(MΒ·N)); block re-scans per outer block to slash I/O; index replaces the inner scan with a fast index lookup (O(MΒ·log N)). It shines when a table is small or an index exists, and struggles on two big un-indexed tables β€” where joins from Session 10 take over.

β˜… 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

Papers, docs & deep dives