πŸ“š Study Notes / Home / DBMS / Session 10
Session 10 Β· Query Execution β€” Joins & Sorting

How databases sort, join, and group data that's too big for memory

In Session 9 we met the building blocks of query execution β€” operators, scans, and how rows flow through a plan. Now we tackle the hard part: what do you do when the data is bigger than your computer's memory? We assume you've studied none of this before. Each topic starts with a tiny "explain like I'm 5" story, then we build up the real algorithms with pseudocode and cost formulas. Take it slow β€” by the end you'll understand the engines that make ORDER BY, JOIN, and GROUP BY fast.

⏱ 27 min readπŸ“– 4 topics

1 External merge sort β€” sorting data bigger than RAM


Explain like I'm 5

Imagine you have a thousand library books dumped on the floor and you need them alphabetised, but your desk only fits ten books at a time. You can't see them all at once. So you grab ten, sort just those, and put that neat little stack on a shelf. You repeat until the floor is empty and the shelf has lots of small sorted stacks. Then you merge: look at the top book of each stack, take whichever comes first alphabetically, and keep going. That two-step trick β€” make small sorted piles, then merge them β€” is exactly how a database sorts more data than fits in memory.

Sorting sounds simple, but a database often must sort a table that's far larger than the memory it's allowed to use. A laptop algorithm like quicksort assumes the whole array fits in RAM. Databases can't assume that. The solution is external merge sort β€” "external" meaning it uses external storage (disk) as scratch space.

The unit of work: pages and buffers

Recall from earlier sessions that a database reads and writes the disk in fixed-size chunks called pages (also called blocks). The database is given a chunk of memory measured in pages β€” we'll call the number of memory pages available B (for "buffers"). Let N be the number of pages in the table we want to sort. The whole game is: sort all N pages while only ever holding B of them in memory at once.

Phase 1 β€” Create sorted "runs"

A run is a chunk of data that is internally sorted. In the first pass we read the table B pages at a time, sort those pages in memory (any in-memory sort works), and write the result back to disk as one sorted run. Each pass over a group of pages produces one run of length B. After reading the whole table we have produced ⌈N / BβŒ‰ sorted runs.

Phase 2 β€” Merge runs together

Now we repeatedly merge runs into bigger runs. With B memory pages we can merge B βˆ’ 1 runs at once (each input run needs one page to read from, and we keep one page as the output buffer). This is called a (Bβˆ’1)-way merge. We look at the smallest unread record across all input pages, copy it to the output, refill pages as they empty, and write out output pages as they fill. Each merge pass reduces the number of runs by a factor of B βˆ’ 1 until only one sorted run remains: the answer.

πŸ“₯
Pass 0
Sort B pages at a time β†’ runs of size B
β†’
πŸ”€
Pass 1
(Bβˆ’1)-way merge β†’ fewer, bigger runs
β†’
πŸ”€
Pass 2…
Keep merging
β†’
βœ…
Done
One fully sorted run

The I/O cost formula

Databases measure sort cost in page I/Os (disk reads + writes), because disk is the bottleneck β€” not CPU. Every pass reads all N pages and writes all N pages, so one pass costs 2N I/Os. The number of passes is: 1 pass to make the initial runs, then merge passes that each shrink the run count by a factor of B βˆ’ 1.

Key formula

Number of passes = 1 + ⌈logBβˆ’1(⌈N / BβŒ‰)βŒ‰
Total I/O cost = 2N Γ— (number of passes)

The logarithm is what makes this practical: because each merge pass merges B βˆ’ 1 runs at once and B is often large (hundreds or thousands of pages), the number of passes is tiny even for huge tables. In many real systems two or three passes sort terabytes.

Worked example

Suppose N = 108 pages and B = 5 memory pages.

  • Pass 0: sort 5 pages at a time β†’ ⌈108 / 5βŒ‰ = 22 sorted runs.
  • Merge passes: each merges B βˆ’ 1 = 4 runs at once.
    • Pass 1: ⌈22 / 4βŒ‰ = 6 runs.
    • Pass 2: ⌈6 / 4βŒ‰ = 2 runs.
    • Pass 3: ⌈2 / 4βŒ‰ = 1 run. Done.

That's 4 passes total (1 + 3). Cost = 2 Γ— 108 Γ— 4 = 864 page I/Os. Check the formula: 1 + ⌈log4(22)βŒ‰ = 1 + ⌈2.23βŒ‰ = 1 + 3 = 4. βœ“

Pseudocode

// External merge sort: N pages of input, B memory pages
function externalSort(table, B):
    // --- Phase 1: build initial sorted runs ---
    runs = []
    while not table.atEnd():
        chunk = table.read(B)          // up to B pages
        inMemorySort(chunk)            // e.g. quicksort
        runs.append(writeToDisk(chunk))

    // --- Phase 2: merge until one run remains ---
    while runs.length > 1:
        nextRuns = []
        for group in chunksOf(runs, B - 1):   // (B-1)-way merge
            merged = mergeRuns(group)              // heap of run heads
            nextRuns.append(merged)
        runs = nextRuns
    return runs[0]

The heart of Phase 2: a k-way merge with a min-heap

The merge step needs to repeatedly find the smallest unread record across all k input runs. Scanning every run head each time would cost O(k) per record; a min-heap (priority queue) keyed on the run heads makes it O(log k). Below is the in-memory k-way merge of already-sorted runs β€” the core inner loop a real engine runs on each group of B βˆ’ 1 runs. (When data exceeds memory, those input runs are produced by Phase 1, sorting B pages at a time and spilling each sorted chunk to disk; here we keep them in lists to show the merge itself.)

# k-way merge of already-sorted runs using a min-heap.
# Each heap entry is (value, run_index, pos_within_run).
import heapq

def kway_merge(runs):
    # Each run is a list sorted ascending. Returns one merged sorted list.
    heap = []
    for i, run in enumerate(runs):     # seed heap with each run's head
        if run:
            heapq.heappush(heap, (run[0], i, 0))
    out = []
    while heap:
        val, i, pos = heapq.heappop(heap)   # smallest head across all runs
        out.append(val)
        if pos + 1 < len(runs[i]):         # refill from the SAME run
            heapq.heappush(heap, (runs[i][pos + 1], i, pos + 1))
    return out

if __name__ == "__main__":
    runs = [[1, 4, 7], [2, 3, 9], [5, 6, 8]]   # 3 sorted runs
    print(kway_merge(runs))                  # [1, 2, 3, 4, 5, 6, 7, 8, 9]
// k-way merge of already-sorted runs using a min-heap.
// Each heap entry is {value, runIndex, posWithinRun}.
#include <iostream>
#include <vector>
#include <queue>

struct Item {
    int value, run, pos;
    bool operator>(const Item& o) const { return value > o.value; } // min-heap
};

std::vector<int> kwayMerge(const std::vector<std::vector<int>>& runs) {
    // greater<> turns the default max-heap into a min-heap
    std::priority_queue<Item, std::vector<Item>, std::greater<Item>> heap;
    for (int i = 0; i < (int)runs.size(); ++i)   // seed with each run's head
        if (!runs[i].empty()) heap.push({runs[i][0], i, 0});

    std::vector<int> out;
    while (!heap.empty()) {
        Item t = heap.top(); heap.pop();        // smallest head across runs
        out.push_back(t.value);
        if (t.pos + 1 < (int)runs[t.run].size())  // refill from SAME run
            heap.push({runs[t.run][t.pos + 1], t.run, t.pos + 1});
    }
    return out;
}

int main() {
    std::vector<std::vector<int>> runs = {{1,4,7},{2,3,9},{5,6,8}};
    for (int v : kwayMerge(runs)) std::cout << v << ' ';
    std::cout << "\n";                          // 1 2 3 4 5 6 7 8 9
}
// k-way merge of already-sorted runs using a min-heap.
// Each heap entry is (value, runIndex, posWithinRun).
import java.util.*;

public class KWayMerge {
    record Item(int value, int run, int pos) {}

    static List<Integer> kwayMerge(List<List<Integer>> runs) {
        // PriorityQueue is a min-heap; order by value
        PriorityQueue<Item> heap = new PriorityQueue<>(Comparator.comparingInt(Item::value));
        for (int i = 0; i < runs.size(); i++)      // seed with each run's head
            if (!runs.get(i).isEmpty())
                heap.add(new Item(runs.get(i).get(0), i, 0));

        List<Integer> out = new ArrayList<>();
        while (!heap.isEmpty()) {
            Item t = heap.poll();                  // smallest head across runs
            out.add(t.value());
            if (t.pos() + 1 < runs.get(t.run()).size())  // refill from SAME run
                heap.add(new Item(runs.get(t.run()).get(t.pos() + 1), t.run(), t.pos() + 1));
        }
        return out;
    }

    public static void main(String[] args) {
        List<List<Integer>> runs = List.of(List.of(1,4,7),
                                          List.of(2,3,9),
                                          List.of(5,6,8));
        System.out.println(kwayMerge(runs));       // [1, 2, 3, 4, 5, 6, 7, 8, 9]
    }
}
A common optimisation: replacement selection

Smart sorters use a heap during Pass 0 to produce longer initial runs (on average 2B instead of B), cutting the run count and sometimes a whole merge pass. Many engines also overlap I/O with CPU using double buffering. You don't need the details now β€” just know the basic two-phase shape is the core.

Why sorting underpins so much

Sorting isn't just for ORDER BY. It also powers sort-merge joins (Topic 2), duplicate elimination (DISTINCT), sorted grouping (Topic 4), and building ordered indexes. A fast external sort makes a whole family of operators fast β€” which is why it's one of the first things a query engine gets right.

Recap External merge sort sorts data bigger than memory in two phases: build sorted runs of B pages, then repeatedly do a (Bβˆ’1)-way merge. Cost is 2N Γ— (1 + ⌈logBβˆ’1⌈N/BβŒ‰βŒ‰) page I/Os β€” only a handful of passes even for enormous tables. It's the foundation for sorting, joins, and grouping.

2 Sort-merge join


Explain like I'm 5

Imagine two teachers each have a class list sorted by student ID, and they want to find students who appear on both lists. Instead of checking every name against every other name (slow!), they each put a finger on the top of their list. If the IDs match β€” great, write it down. If one ID is smaller, that teacher moves their finger down by one. Because both lists are sorted, they only ever move forward, sweeping down both lists once like a zipper. That zipper sweep over two sorted lists is a sort-merge join.

A join combines rows from two tables that match on a condition, usually equality on a shared key (an equi-join), like orders.customer_id = customers.id. The sort-merge join works in two stages: (1) sort both inputs on the join key, then (2) merge them with the zipper sweep.

Stage 1 β€” Sort both sides

We sort both tables on the join key, typically using the external merge sort from Topic 1. If a side is already sorted on that key β€” say it arrives from an index scan, or is the output of an earlier sort β€” we skip its sort entirely. That's the single biggest reason to choose this join.

Stage 2 β€” Merge the sorted inputs

We walk both sorted inputs with two cursors. Compare the join keys at the cursors:

  • If left.key < right.key β†’ advance the left cursor.
  • If left.key > right.key β†’ advance the right cursor.
  • If they're equal β†’ output the matching pair(s), then advance.

Handling duplicates

The tricky case is duplicate keys β€” when many left rows and many right rows share the same key, every left must pair with every right (a small cross-product for that key group). To do this we mark the start of the matching group on the right and, for each left row with that key, replay the whole right group. After the keys differ, we move on. This is why the inner loop below "rewinds" the right cursor for repeated keys.

// Sort-merge join on equality of key k
sort(L) by k;  sort(R) by k
i = 0;  j = 0
while i < L.size and j < R.size:
    if L[i].k < R[j].k:  i += 1
    elif L[i].k > R[j].k:  j += 1
    else:                              // keys match
        jStart = j                     // remember start of R group
        while i < L.size and L[i].k == R[jStart].k:
            j = jStart
            while j < R.size and R[j].k == L[i].k:
                emit(L[i], R[j])       // every L Γ— every R in group
                j += 1
            i += 1
Worked example

Join L (left, sorted) with R (right, sorted) on key k:

L.k = [10, 20, 20, 30]    R.k = [20, 20, 30, 40]

  • 10 < 20 β†’ advance L. Now L=20.
  • 20 == 20 β†’ emit the group: the two L-rows with key 20 each pair with the two R-rows with key 20 β†’ 4 result rows.
  • Move past the 20s. Now L=30, R=30 β†’ match β†’ emit 1 row (30,30).
  • Now L is exhausted; R=40 has no partner. Stop.

Result: 5 rows total. We swept each list essentially once β€” no nested re-scanning of unmatched rows.

Complexity & cost

If sorting both sides costs Sort(M) and Sort(N) page I/Os, and the merge sweep reads each input once for M + N, the total is:

Cost

Sort(L) + Sort(R) + (M + N) page I/Os (in the common case with no enormous duplicate groups). If both inputs are already sorted, the sort terms vanish and you pay just the linear merge sweep M + N β€” beautifully cheap.

When it shines β€” and when it doesn't

Shines when: inputs are already sorted (e.g. from a clustered index or a prior ORDER BY), or when the query also needs the output sorted (then the sort isn't "wasted"), or for range/band joins like L.k BETWEEN R.lo AND R.hi where hashing can't help. Suffers when: neither side is sorted and the result isn't needed sorted β€” then you pay two full sorts, and a hash join (Topic 3) is usually faster. Also watch: many duplicate keys cause big group cross-products that blow up the cost.

Recap Sort-merge join = sort both inputs on the join key, then zipper-merge them with two cursors. Cost is Sort(L) + Sort(R) + (M + N), dropping to just M + N when inputs are pre-sorted. It's the go-to when inputs are already ordered, when output order is needed, or for range joins; duplicate keys need careful group handling.

3 Hash join (and grace / partitioned hash join)


Explain like I'm 5

Imagine you have a small box of name tags and a long line of people, and you want to hand each person their tag. Sorting everyone alphabetically would be slow. Instead you build a quick lookup: a set of labelled cubbies, one per first letter, and drop each tag into its cubby. Now when a person walks up, you instantly jump to the cubby for their first letter and check just those few tags. The cubbies are a hash table, and using it to match two groups is a hash join. You build the cubbies from the smaller group and "probe" them with the bigger one.

A hash join is usually the fastest way to do an equi-join when neither input is sorted. It relies on a hash table: a structure that turns a key into a slot via a hash function, so you can find matching rows in roughly constant time instead of scanning.

The two phases: Build and Probe

πŸ—οΈ
Build
Hash the smaller table into a table in memory
β†’
πŸ”Ž
Probe
For each row of the bigger table, look up its key
β†’
🀝
Match
Emit pairs that land in the same bucket and truly match

Build phase: scan the smaller input (the build side) and insert every row into a hash table keyed on the join key. We pick the smaller side so the table is more likely to fit in memory. Probe phase: scan the larger input (the probe side); for each row, hash its key, jump to that bucket, and emit a result for every build row in the bucket that actually matches (hashing can put different keys in the same bucket β€” a collision β€” so we still compare the real keys).

# In-memory hash join: build on smaller S, probe with larger R.
# Each row is (key, payload). We join on the key.
from collections import defaultdict

def hash_join(build, probe):
    # --- Build phase: hash the SMALLER relation by key ---
    table = defaultdict(list)          # key -> list of build rows
    for key, payload in build:
        table[key].append((key, payload))
    # --- Probe phase: scan the LARGER relation ---
    out = []
    for rkey, rpay in probe:
        for srow in table.get(rkey, ()):   # dict guards collisions for us
            out.append((srow, (rkey, rpay)))
    return out

# --- Tiny demo: customers (small) JOIN orders (big) on customer_id ---
if __name__ == "__main__":
    customers = [(1, "Alice"), (2, "Bob"), (3, "Cara")]
    orders    = [(2, "ord-A"), (2, "ord-B"), (3, "ord-C"), (9, "ord-D")]
    for s, r in hash_join(customers, orders):
        print(s[1], "<->", r[1])
    # Alice never matches; customer 9 has no row => both dropped.
    # Output: Bob <-> ord-A / Bob <-> ord-B / Cara <-> ord-C
// In-memory hash join: build on smaller S, probe with larger R.
// Each row is {key, payload}. We join on the key.
#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>

struct Row { int key; std::string payload; };

std::vector<std::pair<Row, Row>>
hashJoin(const std::vector<Row>& build, const std::vector<Row>& probe) {
    // --- Build phase: hash the SMALLER relation by key ---
    std::unordered_map<int, std::vector<Row>> table;   // key -> build rows
    for (const Row& s : build) table[s.key].push_back(s);

    // --- Probe phase: scan the LARGER relation ---
    std::vector<std::pair<Row, Row>> out;
    for (const Row& r : probe) {
        auto it = table.find(r.key);          // map guards collisions
        if (it == table.end()) continue;
        for (const Row& s : it->second) out.push_back({s, r});
    }
    return out;
}

int main() {
    // customers (small) JOIN orders (big) on customer_id
    std::vector<Row> customers = {{1,"Alice"},{2,"Bob"},{3,"Cara"}};
    std::vector<Row> orders    = {{2,"ord-A"},{2,"ord-B"},{3,"ord-C"},{9,"ord-D"}};
    for (auto& [s, r] : hashJoin(customers, orders))
        std::cout << s.payload << " <-> " << r.payload << "\n";
    // Output: Bob <-> ord-A / Bob <-> ord-B / Cara <-> ord-C
}
// In-memory hash join: build on smaller S, probe with larger R.
// Each row is (key, payload). We join on the key.
import java.util.*;

public class HashJoin {
    record Row(int key, String payload) {}

    static List<Row[]> hashJoin(List<Row> build, List<Row> probe) {
        // --- Build phase: hash the SMALLER relation by key ---
        Map<Integer, List<Row>> table = new HashMap<>();
        for (Row s : build)
            table.computeIfAbsent(s.key(), k -> new ArrayList<>()).add(s);

        // --- Probe phase: scan the LARGER relation ---
        List<Row[]> out = new ArrayList<>();
        for (Row r : probe)
            for (Row s : table.getOrDefault(r.key(), List.of()))  // guards collisions
                out.add(new Row[]{s, r});
        return out;
    }

    public static void main(String[] args) {
        // customers (small) JOIN orders (big) on customer_id
        List<Row> customers = List.of(new Row(1,"Alice"), new Row(2,"Bob"), new Row(3,"Cara"));
        List<Row> orders    = List.of(new Row(2,"ord-A"), new Row(2,"ord-B"),
                                       new Row(3,"ord-C"), new Row(9,"ord-D"));
        for (Row[] pair : hashJoin(customers, orders))
            System.out.println(pair[0].payload() + " <-> " + pair[1].payload());
        // Output: Bob <-> ord-A / Bob <-> ord-B / Cara <-> ord-C
    }
}
Worked example

Join small table S (customers) with big table R (orders) on customer_id.

  • Build: insert customers β†’ buckets: H[ hash(1) ] = [Alice], H[ hash(2) ] = [Bob], H[ hash(3) ] = [Cara].
  • Probe orders one by one:
    • Order(cust=2) β†’ bucket for 2 β†’ matches Bob β†’ emit (Bob, order).
    • Order(cust=2) β†’ matches Bob again β†’ emit.
    • Order(cust=3) β†’ matches Cara β†’ emit.
    • Order(cust=9) β†’ bucket empty β†’ no match (Customer 9 doesn't exist).

Each order touched only its own bucket β€” no scan of the whole customer table per order.

Complexity

Cost (when the build side fits in memory)

I/O: M + N page I/Os β€” read each input once. CPU: O(M + N) expected, since hash lookups are roughly constant-time. This is why hash join usually beats sort-merge join when nothing is pre-sorted: no N log N sort.

When the hash table doesn't fit: grace / partitioned hash join

The simple version assumes the build side fits in memory. What if it doesn't? The answer is the grace hash join (also called partitioned hash join), and it leans on the same idea as external sort: use the disk and the hash function to break a big problem into small ones.

Partition phase: apply a hash function on the join key to split both inputs into the same set of k partitions written to disk. Because the same hash sends a given key to the same partition number in both tables, a row in partition i of S can only ever match rows in partition i of R. Join phase: for each partition pair, run the ordinary in-memory hash join (build on the smaller partition, probe with the other). We sized partitions so each build partition fits in memory.

// Grace (partitioned) hash join
// --- Partition phase: split both inputs by hash(key) ---
for s in S:  writeToPartition(Sp[ hashA(s.key) ], s)   // k partitions on disk
for r in R:  writeToPartition(Rp[ hashA(r.key) ], r)
// --- Join phase: matching partitions only ---
for i in 0 .. k-1:
    H = {}
    for s in Sp[i]:  H[ hashB(s.key) ].append(s)  // 2nd hash fn
    for r in Rp[i]:
        for s in H[ hashB(r.key) ]:
            if s.key == r.key:  emit(s, r)
Why two different hash functions?

The partition phase uses one hash function (hashA) to split into disk partitions; the in-memory join phase uses a different one (hashB) for the actual bucket lookups. Reusing the same function would put every key in one bucket inside a partition (since they all hashed to that partition), defeating the lookup. Also note: if one partition is still too big to fit (e.g. one super-popular key β€” skew), engines recursively partition it again.

Grace hash join cost

Partitioning reads and writes both inputs once (2(M + N)), and the join phase reads them once more (M + N): total 3(M + N) page I/Os. Still linear β€” no sort β€” which is why it stays competitive even when the data spills to disk.

Recap Hash join builds a hash table on the smaller input, then probes it with the larger β€” about M + N I/Os and no sorting, so it usually wins for unsorted equi-joins. When the build side won't fit in memory, the grace / partitioned variant hashes both inputs into matching disk partitions and joins them one pair at a time, costing about 3(M + N). Hash join only does equality joins, not ranges.

4 Hash aggregation & grouping


Explain like I'm 5

Imagine sorting a big bag of mixed coins by counting how much each type is worth. One way: line every coin up by type first, then walk down counting each run (that's the "sort" way). Another way: set out labelled jars β€” pennies, nickels, dimes β€” and just drop each coin into its jar as you go, adding to a running total written on the jar. No lining up needed. The jars are a hash table, and dropping each row into its group's jar and updating a running total is hash aggregation.

An aggregation collapses many rows into summary numbers: COUNT, SUM, AVG, MIN, MAX. With GROUP BY you get one summary row per group (e.g. total sales per region). There are two main strategies to compute GROUP BY: sorting and hashing.

Strategy A β€” Grouping by sorting

Sort the rows on the grouping key (Topic 1). Now all rows of a group sit next to each other, so a single linear pass can detect each group boundary and emit its aggregate. Cost is dominated by the sort: Sort(N) + N. It's the natural choice if the data is already sorted or if you also need the output sorted.

Strategy B β€” Grouping by hashing

Scan the rows once. For each row, hash its grouping key to find that group's slot in a hash table, and update a running aggregate stored there. No sorting required β€” and no need to keep the individual rows, only one small accumulator per distinct group. This is hash aggregation, and it's usually the winner when the number of distinct groups is small enough that all the accumulators fit in memory.

Computing the aggregates incrementally

The clever part: most aggregates can be maintained with a tiny running state, updated one row at a time.

AggregateRunning state per groupUpdate on each new row vFinal answer
COUNTcountcount += 1count
SUMsumsum += vsum
MIN / MAXbestbest = min/max(best, v)best
AVG(sum, count)sum += v; count += 1sum / count

Note that AVG can't be kept as a single number β€” you must track both the running sum and the count, and divide only at the end.

// Hash aggregation: SELECT region, SUM(amount), COUNT(*)
//                   FROM sales GROUP BY region
H = {}                                  // group key -> running state
for row in sales:
    g = row.region
    if g not in H:
        H[g] = { sum: 0, count: 0 }
    H[g].sum   += row.amount
    H[g].count += 1
for g in H:                            // one output row per group
    emit(g, H[g].sum, H[g].count)
Worked example

Rows: (East, 10), (West, 5), (East, 7), (West, 3), (East, 4) with query SELECT region, SUM(amount), AVG(amount) GROUP BY region.

  • (East,10) β†’ East: sum=10, count=1
  • (West,5) β†’ West: sum=5, count=1
  • (East,7) β†’ East: sum=17, count=2
  • (West,3) β†’ West: sum=8, count=2
  • (East,4) β†’ East: sum=21, count=3

Final: East SUM=21, AVG=21/3=7.0;   West SUM=8, AVG=8/2=4.0. One pass, two small accumulators β€” no sort at all.

When the groups don't fit: partitioned aggregation

If there are too many distinct groups to hold all accumulators in memory, engines use the same trick as grace hash join: hash-partition the rows to disk by group key, then aggregate each partition separately. Same family of idea β€” divide a too-big problem into memory-sized pieces.

Comparison: choosing a join / aggregation strategy

StrategyNeeds sorting?Typical I/O costOutput sorted?Best when…
Sort-merge joinYes (both sides)Sort(L)+Sort(R)+M+NYes (on join key)Inputs pre-sorted, output must be sorted, or range joins
Hash join (in-memory)NoM + NNoUnsorted equi-join, one side fits in memory
Grace hash joinNo3(M + N)NoUnsorted equi-join, build side too big for memory
Sort-based groupingYesSort(N) + NYes (by group key)Data already sorted, or sorted output needed
Hash aggregationNoN (groups fit) / partitioned if notNoFew distinct groups; fastest for plain GROUP BY
The recurring trade-off

See the pattern? Hashing is usually faster but produces no order and needs the working set to fit (or partition). Sorting is more expensive but gives you order for free and degrades gracefully on huge data. The query optimiser (next session) weighs these β€” using statistics about table sizes, how many distinct values exist, and whether the output order is needed β€” to pick the cheapest plan.

Recap GROUP BY can be done by sorting (group rows adjacently, then one pass) or by hashing (drop each row into its group's slot and update a running aggregate). COUNT/SUM/MIN/MAX need one accumulator; AVG needs both sum and count. Hashing is usually faster and unordered; sorting costs more but yields ordered output and scales to disk via partitioning.

β˜… Putting it all together


You just learned the heavy machinery behind ORDER BY, JOIN, and GROUP BY on data that's too big for memory. Here's the one-paragraph story connecting all four topics:

The big idea

The whole field of physical query execution turns on one question: how do you process more data than fits in memory using as few disk I/Os as possible? Two grand strategies answer it. Sorting β€” via external merge sort (runs + multi-way merge) β€” gives you ordered data, which directly enables the sort-merge join (the zipper sweep over two sorted inputs) and sort-based grouping. Hashing β€” building a lookup table β€” gives you the faster hash join and hash aggregation, falling back to grace/partitioned variants that spill matching pieces to disk when memory runs out. Hashing is fast but unordered; sorting is pricier but gives order for free. In the next session, the query optimiser uses cost formulas exactly like the ones here to choose between them automatically.

Quick self-check

Why does external merge sort use disk at all β€” why not just quicksort?

Because the table is bigger than the memory the database is allowed to use. Quicksort assumes the whole array fits in RAM; external merge sort holds only B pages at a time, making sorted runs and merging them so it never needs the whole table in memory at once.

You sort 100 pages with 6 memory pages. How many passes, and what's the I/O cost?

Pass 0 makes ⌈100/6βŒ‰ = 17 runs. Each merge is (Bβˆ’1)=5-way: 17β†’βŒˆ17/5βŒ‰=4β†’βŒˆ4/5βŒ‰=1. That's 1 + 2 = 3 passes, cost 2 Γ— 100 Γ— 3 = 600 I/Os.

Both inputs already arrive sorted on the join key. Sort-merge or hash join?

Sort-merge join β€” the expensive sort stage is already done, so you pay only the linear merge sweep M + N, which beats hashing here. It also keeps the output sorted.

In a hash join, which side do you build the hash table on, and why?

The smaller input. A smaller hash table is more likely to fit entirely in memory, letting you do the join in a single in-memory pass while probing with the larger side.

Why must AVG track two numbers instead of one?

AVG = sum Γ· count, and you can't average a stream by keeping just a running average (you'd lose how many rows it represents). You keep a running sum and count, then divide once at the very end.

What's the shared trick behind grace hash join and partitioned aggregation?

Hash-partition the data to disk so that matching keys (or groups) always land in the same partition, then process one memory-sized partition at a time. It turns a too-big problem into many small in-memory ones β€” the same divide-and-conquer spirit as external merge sort.

πŸ“š References & Further Reading


Class material

  • πŸ“„ Original course notes / handout (source sheet) β€” open the shared class material for this session.
  • πŸ“˜ Class handout: "DBMS Session 10 β€” Query Execution 2" β€” covers external sort, sort-merge join, hash join, and aggregation strategies.

Papers, docs & deep dives