1 External merge sort β sorting data bigger than RAM
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.
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.
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.
Suppose N = 108 pages and B = 5 memory pages.
- Pass 0: sort 5 pages at a time β
β108 / 5β = 22sorted runs. - Merge passes: each merges
B β 1 = 4runs at once.- Pass 1:
β22 / 4β = 6runs. - Pass 2:
β6 / 4β = 2runs. - Pass 3:
β2 / 4β = 1run. Done.
- Pass 1:
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] } }
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.
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.
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
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
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=40has 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:
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.
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.
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)
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 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 } }
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
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)
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.
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.
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
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.
| Aggregate | Running state per group | Update on each new row v | Final answer |
|---|---|---|---|
| COUNT | count | count += 1 | count |
| SUM | sum | sum += v | sum |
| MIN / MAX | best | best = min/max(best, v) | best |
| AVG | (sum, count) | sum += v; count += 1 | sum / 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)
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.
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
| Strategy | Needs sorting? | Typical I/O cost | Output sorted? | Best when⦠|
|---|---|---|---|---|
| Sort-merge join | Yes (both sides) | Sort(L)+Sort(R)+M+N | Yes (on join key) | Inputs pre-sorted, output must be sorted, or range joins |
| Hash join (in-memory) | No | M + N | No | Unsorted equi-join, one side fits in memory |
| Grace hash join | No | 3(M + N) | No | Unsorted equi-join, build side too big for memory |
| Sort-based grouping | Yes | Sort(N) + N | Yes (by group key) | Data already sorted, or sorted output needed |
| Hash aggregation | No | N (groups fit) / partitioned if not | No | Few distinct groups; fastest for plain GROUP BY |
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.
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 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
- CMU 15-445 Database Systems β the gold-standard course; see the lectures on sorting/aggregation and on join algorithms for slides and recordings of exactly these topics.
- Database System Concepts (Silberschatz, Korth, Sudarshan) β the query-processing chapter walks through external sort-merge, sort-merge join, and hash join with the same cost formulas used here.
- Goetz Graefe, "Query Evaluation Techniques for Large Databases" (ACM Computing Surveys, 1993) β the classic, deeply readable survey of sorting, joins, and hashing in real query engines.
- PostgreSQL Documentation β Planner/Optimizer β see how a production system actually chooses between merge join, hash join, and sort vs. hash aggregation.
- Use The Index, Luke! β a friendly, example-driven guide to how indexes and sorted data make joins and ordering fast.