Database Management Systems (DBMS) Module 10 – Query processing and optimisation
Join algorithms: nested loops, sort-merge, hash
How nested loop, sort-merge and hash joins work, what each costs in page I/Os, and how PostgreSQL and SQLite choose one, with plans and runs you can check.
What you will learn
- Compute the I/O costs of simple, block and index nested-loop joins, sort-merge joins and Grace hash joins
- Choose the best join algorithm for given input sizes, memory and indexes
- Explain how SQLite runs every join as nested loops, with automatic indexes and Bloom filters
- Compare join methods in PostgreSQL by steering its planner with the enable_ settings
Before you start
What you’ll learn · Before you start
What you will learn
- Compute the I/O costs of simple, block and index nested-loop joins, sort-merge joins and Grace hash joins
- Choose the best join algorithm for given input sizes, memory and indexes
- Explain how SQLite runs every join as nested loops, with automatic indexes and Bloom filters
- Compare join methods in PostgreSQL by steering its planner with the enable_ settings
Before you start
On this page
A join pairs the rows of two inputs whose join keys match, and database engines have three ways to do it. A nested loop join takes each row, or block of rows, of the outer input and finds its matches in the inner input, by scanning it or through an index. A sort-merge join sorts both inputs on the key and walks them together. A hash join loads the smaller input into a hash table on the key and looks up every row of the other input in it. Which one is fastest depends on the sizes of the inputs, the memory, the indexes and the order the rows arrive in, and the planner prices each candidate before it chooses. This lesson prices all three on one example in page I/Os, then shows SQLite’s nested loops at work in your browser.
Three ways to pair rows
Nested loops, sort-merge and hash join
Text description of the diagram
The diagram has three panels, one per join algorithm, from top to bottom.
1. Nested loops: the outer input is read one row, or one block of rows, at a time, and for each the inner input is scanned or searched through an index to find the matching rows. 2. Sort-merge join: both inputs, R and S, are sorted on the join key, and a merge step walks the two sorted streams together, always advancing the side with the smaller key and pairing rows whose keys are equal. 3. Hash join: the build input, normally the smaller one, is loaded into a hash table on the join key; then each row of the probe input is looked up in that table.
Nested loops come in three forms. The simple nested loop reads the whole inner input once for every outer row. The block nested loop holds B − 2 pages of the outer input in memory and scans the inner input once per block. The index nested loop looks each outer row up in an index on the inner join key.
A sort-merge join sorts both inputs on the join key, unless an index or an earlier step already delivers them in that order, then merges them in one pass over each. Rows with equal keys on both sides must all be paired, so the merge reads a group of equal keys on one side again for every matching row on the other.
A hash join builds a hash table on the smaller input, the build side, and probes it with each row of the larger one. When the table does not fit in memory, a Grace hash join first splits both inputs into partitions by a hash of the key, so that matching rows always land in the same pair of partitions, and then joins the pairs one at a time. A hybrid hash join keeps the first partition in memory while it splits, which saves writing and reading it.
What each join costs in page I/Os
For an outer input of M pages with p rows a page, an inner input of N pages and B pages of memory:
| Join | Page I/Os |
|---|---|
| Simple nested loop | M + p × M × N |
| Block nested loop | M + ⌈M / (B − 2)⌉ × N, lowest with the smaller input outside |
| Index nested loop | M + outer rows × one lookup, with an index on the inner join key |
| Sort-merge | Both sorts, then M + N; 3 × (M + N) when the runs of both inputs merge at once |
| Grace hash | 3 × (M + N), when each partition of the smaller input fits in B − 2 pages |
The program prices them for orders, 1,000 pages of 100 rows, joined to customers, 500 pages, with B = 102:
"""Page I/Os of the classic join algorithms for orders (1,000 pages) joined to customers (500 pages), B = 102."""
from math import ceil
ORDERS, CUSTOMERS = 1_000, 500 # pages
ORDERS_PER_PAGE = 100 # rows per page of orders
B = 102 # buffer pages
def block_nested_loop(outer: int, inner: int) -> int:
"""Read the outer input B - 2 pages at a time; scan the inner input once per block."""
return outer + ceil(outer / (B - 2)) * inner
def sort_passes(pages: int) -> int:
runs, passes = ceil(pages / B), 1
while runs > 1:
runs, passes = ceil(runs / (B - 1)), passes + 1
return passes
costs = {
"nested loop, one outer row at a time": ORDERS + ORDERS * ORDERS_PER_PAGE * CUSTOMERS,
"nested loop, one outer page at a time": ORDERS + ORDERS * CUSTOMERS,
"block nested loop, orders outside": block_nested_loop(ORDERS, CUSTOMERS),
"block nested loop, customers outside": block_nested_loop(CUSTOMERS, ORDERS),
# Sort both inputs completely (each pass reads and writes the file), then read both once to merge.
"sort-merge, sorting each input fully": sum(2 * p * sort_passes(p) + p for p in (ORDERS, CUSTOMERS)),
# 10 + 5 sorted runs fit in one merge (at most B - 1 = 101): the join merges the runs directly.
"sort-merge, merging the runs in the join": 3 * (ORDERS + CUSTOMERS),
# Partition both inputs into B - 1 partitions, write them, read them back pair by pair.
"Grace hash join": 3 * (ORDERS + CUSTOMERS),
}
print(f"orders {ORDERS:,} pages, customers {CUSTOMERS:,} pages, B = {B} buffer pages\n")
for name, ios in sorted(costs.items(), key=lambda kv: kv[1]):
print(f"{name:<42}{ios:>12,} page I/Os")
print(f"\nGrace hash join needs each customers partition to fit: {ceil(CUSTOMERS / (B - 1))} pages <= {B - 2}") Output
orders 1,000 pages, customers 500 pages, B = 102 buffer pages sort-merge, merging the runs in the join 4,500 page I/Os Grace hash join 4,500 page I/Os block nested loop, customers outside 5,500 page I/Os block nested loop, orders outside 6,000 page I/Os sort-merge, sorting each input fully 7,500 page I/Os nested loop, one outer page at a time 501,000 page I/Os nested loop, one outer row at a time 50,001,000 page I/Os Grace hash join needs each customers partition to fit: 5 pages <= 100
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 join_cost.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Grace hash join and the sort-merge join that merges both inputs’ runs at once tie at 4,500 page I/Os. The block nested loop costs 5,500 with customers outside and 6,000 with orders outside, so the smaller input belongs outside. Taking one outer row at a time, the simple nested loop needs 50 million I/Os, which is why no engine runs it on large inputs. The index nested loop is not in the list because its cost depends on how many outer rows there are: it wins when only a few survive a filter.
| Criterion | Index nested loop | Sort-merge join | Hash join |
|---|---|---|---|
| Needs | An index on the inner key | Inputs in key order | An equality condition |
| Memory | Almost none | Enough to sort | A table of the smaller input |
| Order | The outer input’s | Key order | None |
| Best | Few outer rows | Sorted inputs | Large unordered inputs |
| Worst | Many outer rows | Two large sorts | One very common key |
| When to choose | A selective filter | Order there or needed | Most large equality joins |
SQLite: every join is a nested loop
SQLite has no hash join and no merge join: its documentation says that it implements every join as nested loops. When the inner table has no index on the join key, it can build one for the query:
-- 600 customers with a pincode each, and 500 pincodes with their districts. pincodes has no index at all.
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, pincode INTEGER NOT NULL);
CREATE TABLE pincodes (pincode INTEGER NOT NULL, district TEXT NOT NULL);
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 600)
INSERT INTO customers SELECT i, 'customer ' || i, 411000 + i % 50 FROM n;
WITH RECURSIVE n(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM n WHERE i < 499)
INSERT INTO pincodes SELECT 411000 + i, 'district ' || (i % 7) FROM n;
-- 1. SQLite's plan: an outer loop over customers and an inner loop that searches an index built for this query.
EXPLAIN QUERY PLAN
SELECT c.name, p.district FROM customers AS c JOIN pincodes AS p ON p.pincode = c.pincode;
-- 2. With automatic indexes switched off, the inner loop scans all 500 pincodes for every customer.
PRAGMA automatic_index = OFF;
EXPLAIN QUERY PLAN
SELECT c.name, p.district FROM customers AS c JOIN pincodes AS p ON p.pincode = c.pincode;
-- 3. Both plans return the same 600 rows.
SELECT count(*) AS joined_rows FROM customers AS c JOIN pincodes AS p ON p.pincode = c.pincode; Output
┌────┬────────┬─────────┬─────────────────────────────────────────────────────┐ │ id │ parent │ notused │ detail │ ├────┼────────┼─────────┼─────────────────────────────────────────────────────┤ │ 3 │ 0 │ 216 │ SCAN c │ │ 7 │ 0 │ 0 │ BLOOM FILTER ON p (pincode=?) │ │ 17 │ 0 │ 53 │ SEARCH p USING AUTOMATIC COVERING INDEX (pincode=?) │ └────┴────────┴─────────┴─────────────────────────────────────────────────────┘ ┌────┬────────┬─────────┬────────┐ │ id │ parent │ notused │ detail │ ├────┼────────┼─────────┼────────┤ │ 3 │ 0 │ 216 │ SCAN c │ │ 5 │ 0 │ 216 │ SCAN p │ └────┴────────┴─────────┴────────┘ ┌─────────────┐ │ joined_rows │ ├─────────────┤ │ 600 │ └─────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < automatic_index.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
The first plan scans customers in the outer loop and searches pincodes through an AUTOMATIC COVERING INDEX in the
inner one. SQLite builds such an index only when it expects the lookups to repay the work of building it, keeps it for
that one statement, and describes it as almost the same thing as a hash join with a B-tree in place of the hash
table. The BLOOM FILTER line is a quick membership test, added to SQLite’s planner in version 3.38.0, that skips the
index search for a pincode that cannot be in the table. With automatic indexes switched off, the second plan scans all
500 pincodes for each of the 600 customers: 300,000 row comparisons for the same 600 rows.
Exercise
Exercise · Medium · Python
Pick the cheaper join from page counts and memory
Write best_join(r_pages, s_pages, b) in choose.py. It returns a tuple (name, page_ios) for the cheaper of two ways to join two inputs of r_pages and s_pages pages with b buffer pages:
"block nested loop", with the smaller input as the outer one:smaller + ceil(smaller / (b - 2)) * larger;"grace hash", which costs3 * (r_pages + s_pages)but only works when every partition of the smaller input fits in memory:ceil(smaller / (b - 1)) <= b - 2. When it does not, leave it out.
When both cost the same, return "grace hash".
Starter code · choose.py
"""Which join is cheaper for two inputs of known size and a given number of buffer pages?"""
from math import ceil
def best_join(r_pages, s_pages, b):
"""(name, page_ios) of the cheaper of a block nested loop join and a Grace hash join."""
return ("block nested loop", 0) The sample tests · test_choose.py
from choose import best_join
def test_lesson_example():
"""1,000 and 500 pages with 102 buffer pages: the hash join's 4,500 I/Os beat the nested loop's 5,500"""
assert best_join(1000, 500, 102) == ("grace hash", 4500)
assert best_join(500, 1000, 102) == ("grace hash", 4500)
def test_small_input_fits():
"""When the smaller input fits in one block, the nested loop reads each input once"""
assert best_join(1000, 50, 102) == ("block nested loop", 1050)
def test_partitions_too_big():
"""With 12 pages, partitions of 10,000 pages cannot fit, so only the nested loop is left"""
assert best_join(10_000, 10_000, 12) == ("block nested loop", 10_010_000)
def test_partition_boundary():
"""With 12 pages, 110 pages split into 11 partitions of 10 pages, which fit; 115 pages do not"""
assert best_join(110, 200, 12) == ("grace hash", 930)
assert best_join(115, 200, 12) == ("block nested loop", 2515)
def test_tie_prefers_hash():
"""When both cost the same, the answer is the hash join: 40 and 40 pages with 10 buffer pages cost 240 either way"""
assert best_join(40, 40, 10) == ("grace hash", 240) A hint
Start with small, large = sorted((r_pages, s_pages)), compute the block nested loop cost from them, and add the Grace hash join to your options only when its condition holds. min with a key that sorts by cost first picks the winner; add a second part to the key that settles a tie.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Nested loops in depth: blocks, indexes and the lookups that add up Pro: opens with a Pro, Premium or Ultimate pass
A nested loop join is only as good as its inner side. This part counts the pages of a block nested loop in a simulation, finds the break-even between index lookups and a single pass with numbers, and sizes the memory a block needs to turn the loop into two plain reads.
In this part:
- Counting the pages of a block nested loop, simulated
- Index lookups against one pass: the break-even, with numbers
- Memory that turns the loop into two reads, with numbers
1 runnable example (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Sort-merge and hash joins: duplicates, partitions and skew Pro: opens with a Pro, Premium or Ultimate pass
Sort-merge and hash joins read their inputs a fixed number of times, until repeated keys or memory get in the way. This part replays a merge join over repeated keys, partitions a Grace hash join with every page counted, drills the skewed key that no partitioning can split, and reads PostgreSQL’s hybrid hash join batch by batch.
In this part:
- Merging repeated keys: inner rows read again
- Grace hash join, partition by partition, with every page counted
- A skewed key that no partitioning can split: the failure drill
- Hybrid hash join: partition 0 stays in memory, with numbers
3 runnable examples (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Choosing a join with numbers, and steering PostgreSQL to test it Pro: opens with a Pro, Premium or Ultimate pass
A planner chooses among the three by estimated cost, and PostgreSQL lets a session overrule it for an experiment. This part plans one join four ways with their costs and buffers, times the four plans on one computer, and turns the results into rules for choosing.
In this part:
- The same join planned four ways in PostgreSQL 18
- Measured on one computer: the four plans timed
- A decision table for choosing a join
2 runnable examples (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher · Warm-up
Name the three families of join algorithms and say when each is the natural choice.
Nested loops take each row, or block of rows, of the outer input and find its matches in the inner input; with an index on the inner join key they are the natural choice when few outer rows survive a filter. A sort-merge join sorts both inputs on the key and merges them; it fits when the inputs are already in key order, from an index or an earlier step, or when the output must be ordered by the key. A hash join builds a hash table on the smaller input and probes it with every row of the other; it is the usual choice for equality joins of large, unordered inputs, and with partitions (Grace or hybrid hash) it works even when the table does not fit in memory. Only nested loops handle join conditions that are not equalities.
-
Fresher to mid
Orders has 2,000 pages, customers 800 pages, and the join may use 52 buffer pages. Price a block nested loop, a sort-merge join and a Grace hash join in page I/Os, choose one, and say what would change your choice.
-
Mid to senior
A query joins 20,000 customers to 200,000 orders, with an index on orders.customer_id. Filtering for one customer, the planner uses an index nested loop; for a whole city, a hash join. Estimate where the switch should happen, and what would move that point.
-
Senior to staff
A nightly job joins 50 million deliveries to 20 million orders with a hash join built on deliveries. After a bulk import gave 30 % of the deliveries a placeholder order id, the join spills, its memory use explodes, and more work_mem does not help. Explain what is happening and how you would fix it.
Key takeaways
- Nested loops find matches for each outer row or block, sort-merge walks two sorted inputs together, and a hash join probes a table built on the smaller input.
- Block nested loop costs M + ⌈M / (B − 2)⌉ × N: put the smaller input outside. Grace hash join costs 3 × (M + N) when its partitions fit.
- An index nested loop wins only when few outer rows survive, and the other two win for large unordered inputs.
- SQLite runs every join as nested loops and builds automatic indexes when they pay off; PostgreSQL chooses among Nested Loop, Merge Join and Hash Join by cost.
References
- PostgreSQL 18 Documentation: Planner/Optimizer (the three join strategies) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Planner Method Configuration (enable_hashjoin, enable_mergejoin, enable_nestloop, enable_memoize) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Using EXPLAIN (merge joins and repeated inner rows) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Release Notes (disabled nodes in EXPLAIN) (The PostgreSQL Global Development Group)
- PostgreSQL 18 source: nodeHashjoin.c (hybrid hash join and batches) (The PostgreSQL Global Development Group)
- PostgreSQL 18 source: nodeHash.c and hashjoin.h (skew handling and batch growth) (The PostgreSQL Global Development Group)
- The SQLite Query Optimizer Overview (joins, automatic indexes) (SQLite)
- SQLite: EXPLAIN QUERY PLAN (SQLite)
- SQLite Release 3.38.0 (Bloom filters in the query planner) (SQLite)
- M. Kitsuregawa, H. Tanaka and T. Moto-oka: Application of Hash to Data Base Machine and Its Architecture (New Generation Computing)
- D. J. DeWitt and others: Implementation Techniques for Main Memory Database Systems (ACM SIGMOD 1984)
- L. D. Shapiro: Join Processing in Database Systems with Large Main Memories (ACM Transactions on Database Systems)
- G. Graefe: Query Evaluation Techniques for Large Databases (ACM Computing Surveys)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress