Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 35 minutes
  • Examples run with sql.js 1.14.2, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 11 of 35 minutes free
Free: Three ways to pair rows · What each join costs in page I/Os · SQLite: every join is a nested loop · 2 more sections. With a pass: 3 in-depth parts and 3 interview answers, about 24 minutes.

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

Three join algorithms: nested loops look up matches per outer row, sort-merge merges two sorted inputs, a hash join probes a table.Nested loopsSort-merge joinmerge: walk both sorted inputs,advance the smaller key,pair the equal keysHash joinouter input:one row or block at a timeinner input:scanned, or searchedthrough an indexR sortedon the keyS sortedon the keybuild: the smaller inputgoes into a hash tableprobe: look up each rowof the other inputfind its matches

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 join algorithms for one pair of tables Python · py/join_cost.py
"""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

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.

Choosing a join algorithm
Criterion Index nested loopSort-merge joinHash join
Needs An index on the inner keyInputs in key orderAn equality condition
Memory Almost noneEnough to sortA table of the smaller input
Order The outer input’sKey orderNone
Best Few outer rowsSorted inputsLarge unordered inputs
Worst Many outer rowsTwo large sortsOne very common key
When to choose A selective filterOrder there or neededMost 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:

An automatic index, and the same join without it SQL · sqlite/automatic_index.sql
-- 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

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.

Join CSV/Excel Files (VLOOKUP Online) Join two CSV or Excel files on a key column in the browser; it matches rows through a hash table, like a hash join.

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 costs 3 * (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.

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.

  1. Question 1 of 6 With 102 buffer pages, what does a block nested loop join cost in page I/Os when customers (500 pages) is the outer input and orders (1,000 pages) the inner one?

    Type a number.

    Show the answer to question 1

    Answer: 5500 page I/Os

    The outer input is read once in blocks of B − 2 = 100 pages, which makes 5 blocks, and the inner input is scanned once per block: 500 + 5 × 1,000 = 5,500. With orders outside it would be 1,000 + 10 × 500 = 6,000.

  2. Question 2 of 6 What does a Grace hash join of the same two inputs cost in page I/Os, if every partition fits in memory?

    Type a number.

    Show the answer to question 2

    Answer: 4500 page I/Os

    Partitioning reads both inputs and writes them back as partitions, 2 × (1,000 + 500), and the join phase reads every partition once more, 1,000 + 500: 3 × 1,500 = 4,500 page I/Os.

  3. Question 3 of 6 The memory shrinks to 52 buffer pages. What does the block nested loop join cost now, with customers outside?

    Type a number.

    Show the answer to question 3

    Answer: 10500 page I/Os

    Blocks hold B − 2 = 50 pages, so the 500 pages of customers make 10 blocks and orders is scanned 10 times: 500 + 10 × 1,000 = 10,500 page I/Os, almost twice the cost with 102 pages.

  4. Question 4 of 6 What does a sort-merge join need before it can merge?

    Choose one answer.

    Show the answer to question 4

    Answer: Both inputs in the order of the join key, from a sort or an index

    The merge walks both inputs in key order, so each must be sorted on the join key, by a sort step or by reading an index in order. It needs neither a hash table nor an index lookup per row.

  5. Question 5 of 6 Which input should a hash join load into its hash table?

    Choose one answer.

    Show the answer to question 5

    Answer: The smaller one, so that the table is small and partitions are needed less often

    The hash table has to fit in memory, and the smaller input makes the smaller table. The other input is only streamed through, one row at a time.

  6. Question 6 of 6 When is an index nested loop join likely to be the cheapest choice?

    Choose every answer that is right.

    Show the answer to question 6

    Answer:

    • The query needs only the first few joined rows, and the inner join key is indexed
    • The outer input has few rows after its filter, and the inner join key is indexed

    Each outer row costs one index lookup, so the join is cheap when there are few outer rows, or when a LIMIT stops it early. With large inputs, a lookup per row costs far more than one pass of a hash or merge join, and without an index the inner side has to be scanned for every outer row or block.

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.

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.

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.

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

  1. 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.

  2. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

  4. 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.

    Model answer, Pro. First step and follow-ups

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.