Featured image of post Hash Join vs Merge Join vs Nested Loop: When Each One Wins

Hash Join vs Merge Join vs Nested Loop: When Each One Wins

Fetching the orders of 1,000 customers from a five-million-row table took 68 ms on PostgreSQL 18.6, by looking each customer up in the orders index. Fetching all 200,000 customers' orders the same way took 13.5 seconds. The same query, run another way, finished in under one. A join, it turns out, is not one operation but a choice among several, and the planner, the part of the database that decides how a query runs, makes it by how many rows it expects to handle. PostgreSQL has three join algorithms to choose from and MySQL has two, and which one each picks, and when, is what this post is about.

(updated )
TL;DR
A join algorithm is the procedure a database uses to match the rows of one table with the rows of another, and PostgreSQL has three: nested loop, hash join and merge join. This post covers what each one needs, how to recognise each in a query plan (the database’s printout of how it runs a query), where the planner, the part that picks the plan, switches from one to the next, and what makes it switch at the wrong point; MySQL, which has two of the three, gets its own section. Every number comes from runs on PostgreSQL 18.6 and MySQL 8.4.13.
Nested loopHash joinMerge join
How it worksFor each row of the first input, look up matching rows in the secondLoad one input into a hash table in memory, then check every row of the other against itWalk two inputs sorted on the join column side by side
What it needsAn index on the second table’s join columnMemory for the hash table (work_mem in PostgreSQL, join_buffer_size in MySQL)Both inputs in join-column order, from an index or a sort
Fastest hereUnder about 5,000 customers: 68 ms against 381 ms for hash at 1,000, with merge join tied because it walks the same indexPast about 5,000: 360 ms against 1,219 ms for nested loop at 20,000Two big tables in key order: 1,084 ms against 1,621 ms hash and 3,029 ms nested loop
How it failsCost grows with every outer row: 13.5 s at 200,000 customers, against 0.86 s for hashStarts by reading the whole second table, so it took 268 ms even for 10 customersWithout an index to supply the order, a sort comes first
PostgreSQL 18Chosen by costChosen by costChosen by cost
MySQL 8.4Chosen whenever an index fits the joinUsed only when no index fits the joinDoes not exist

All of it is measured below except the sort in merge join’s failure row, which is documented behavior these tests never triggered.

So here is how a join works, in outline. Looking up each row of one table in the other is the natural first answer, and with an index on the second table’s join column (a sorted copy of that column with pointers back to the rows) it is a good one, because each lookup goes straight to the few rows that match. But every lookup is a separate trip, into the index and then out to wherever those rows sit in the table, and a join that needs a large share of the table makes one such trip per row. Past some point it is cheaper to read the whole table once, front to back, and match rows as they go by. Databases do that in two ways: hold one side in a lookup table in memory and stream the other side past it, or read both sides in the same sorted order and pair them off. Counting the lookup, that makes three algorithms, called nested loop, hash join and merge join, and before each query PostgreSQL’s planner, the part that picks how a query runs, estimates the rows each step will produce, prices the options and runs the cheapest.

A quick reminder about query plans

The planner’s choice is printed in the query plan, the tree of steps the database runs to answer a query. EXPLAIN shows the plan, and EXPLAIN ANALYZE runs the query and adds what actually happened. Each step is a node, indented under the node that uses its output, and each carries two estimates: rows, how many rows the planner expects it to produce, and cost, a number in arbitrary units that only means something next to the cost of another plan. A join is a node with two children, the two inputs it matches. The habit this post leans on is reading the estimated rows against the actual ones, because the planner chose its join from the estimate; the how-tos for PostgreSQL and MySQL cover the rest of the output (MySQL calls its planner the optimizer).

Three ways to join two tables

Each of the three needs something different from its inputs, and what it needs decides how its cost grows as the inputs grow. That growth is what the planner is really comparing when it prices them.

The tests ran on PostgreSQL 18.6 and MySQL 8.4.13 in Docker with default settings, each container limited to 4 CPUs and 4 GB of memory, on the same tables as the two EXPLAIN posts: 200,000 customers and 5,000,000 orders, each order belonging to a random customer, with an index on orders.customer_id. A third table, shipments, holds one row for each of the 4,950,073 shipped orders, keyed by order id and written in order id order. PostgreSQL can split one join across several processes, and it can compile parts of a query at run time (JIT); I turned both off, as in the EXPLAIN post, so parallel joins are simplified away here. Every join is also an inner join on one equality, such as o.customer_id = c.id; outer joins and other conditions change which of the three are allowed, which this post leaves aside. Timings are medians of five runs (three for the biggest joins) with the tables already read once. Run-to-run noise between sessions was up to about 20 percent, so smaller differences mean nothing.

The query for most of this post totals the orders of the first N customers:

1
2
3
4
SELECT count(*), sum(o.total)
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.id <= 1000;

Nested loop

A nested loop is the lookup from the outline, and what it needs is an index on the second input’s join column, because it searches that input once for every row of the first. Without one, each search would have to read the whole orders table. With 1,000 customers PostgreSQL picks it (plan trimmed to the join and its two inputs):

1
2
3
4
5
6
7
 Nested Loop  (cost=0.85..93322.60 rows=24625 width=6) (actual time=0.010..54.458 rows=24899.00 loops=1)
   Buffers: shared hit=8343 read=19558
   ->  Index Only Scan using customers_pkey on customers c  (cost=0.42..29.66 rows=985 width=4) (actual time=0.004..0.068 rows=1000.00 loops=1)
         Index Cond: (id <= 1000)
   ->  Index Scan using orders_customer_id_idx on orders o  (cost=0.43..94.45 rows=26 width=10) (actual time=0.003..0.053 rows=24.90 loops=1000)
         Index Cond: (customer_id = c.id)
 Execution Time: 55.356 ms

The first child is called the outer side, and here it produces 1,000 customers. The second child, the inner side, runs once per customer (loops=1000), and each run uses the index to find that customer’s 25 or so orders. So the work grows with the outer side: twice the customers, twice the index lookups. Buffers counts pages, the 8 KB blocks PostgreSQL stores a table in, and this join touched 27,901 of them, because each customer’s orders are scattered across the table and almost every order lives on a different page.

Hash join

A hash join needs no index at all. What it needs is memory, for a hash table: a lookup structure, keyed on the join column, that it builds from one input before reading the other. Raise the limit to 20,000 customers and the plan changes to one:

1
2
3
4
5
6
7
 Hash Join  (cost=804.18..145898.06 rows=489805 width=6) (actual time=1.398..387.738 rows=500284.00 loops=1)
   Hash Cond: (o.customer_id = c.id)
   ->  Seq Scan on orders o  (cost=0.00..131968.48 rows=5000048 width=10) (actual time=0.066..179.694 rows=5000000.00 loops=1)
   ->  Hash  (cost=559.28..559.28 rows=19592 width=4) (actual time=1.328..1.328 rows=20000.00 loops=1)
         Buckets: 32768  Batches: 1  Memory Usage: 960kB
         ->  Index Only Scan using customers_pkey on customers c  (cost=0.42..559.28 rows=19592 width=4) (actual time=0.005..0.658 rows=20000.00 loops=1)
 Execution Time: 407.853 ms

Read it from the bottom. The Hash node collects the 20,000 customer ids into the hash table, which took 960 kB and a millisecond. Then Seq Scan on orders reads all 5,000,000 orders once, and for each one the join checks the hash table for its customer_id. Nothing runs more than once (loops=1 everywhere), and the index on orders.customer_id is not used at all. Most of the cost is that one scan of the orders table, and it is the same whether the hash table holds 20 customers or 20,000: with only 10 customers a forced hash join still took 268 ms. The memory limit comes from work_mem in PostgreSQL and join_buffer_size in MySQL, and Batches: 1 means the hash table fit inside it. That line comes back below.

Merge join

A merge join needs both inputs sorted on the join column, either read from an index in that order or sorted first, and in return it reads each input once. The customers-and-orders query cannot get its orders in that order cheaply, as the next section shows. The shipments join can, because both it and orders have primary keys on the order id:

1
2
3
SELECT count(*), sum(s.weight_g)
FROM orders o
JOIN shipments s ON s.order_id = o.id;
1
2
3
4
5
 Merge Join  (cost=12.71..366817.13 rows=4950090 width=4) (actual time=0.017..967.736 rows=4950073.00 loops=1)
   Merge Cond: (o.id = s.order_id)
   ->  Index Only Scan using orders_pkey on orders o  (cost=0.43..129849.15 rows=5000048 width=8) (actual time=0.004..188.127 rows=5000000.00 loops=1)
   ->  Index Scan using shipments_pkey on shipments s  (cost=0.43..162601.78 rows=4950090 width=12) (actual time=0.011..337.965 rows=4950073.00 loops=1)
 Execution Time: 1083.770 ms

Both inputs come out of their primary key indexes already in order id order. The join reads one row from each side. If the ids match, that is a result row; if not, it moves the side with the smaller id forward. Each table is read once, front to back, with no hash table and no sort, and the Merge Cond line gives it away.

This is the case merge join exists for: two big tables, every row needed, both already in key order. Forcing the other two algorithms onto the same join shows why the planner picked it:

Bar chart: merge join 1,084 ms, hash join 1,621 ms with 64 batches, nested loop 3,029 ms with 4,950,073 primary key lookups.

The hash join has to load 4,950,073 shipments into its hash table before it can match anything, and at the default work_mem that table did not fit, so PostgreSQL split it into 64 batches written to temporary files (Batches: 64 under the Hash node). The nested loop is less awful than its 4,950,073 lookups suggest, because it scans shipments in order id order, so consecutive lookups into the orders primary key land on the same index pages.

Where the choice goes wrong, and two fixes that backfire

The planner prices the three from estimates, and on the customers-and-orders query the break-even point it worked from was about a third of the one measured. To find the real one, I ran that query at 17 sizes, from 10 customers to all 200,000, and forced each algorithm in turn with enable_hashjoin, enable_mergejoin and enable_nestloop set to off for the session.

Line chart: nested loop time rises in a straight line from near zero to 1,219 ms at 20,000 customers, while hash join stays flat around 300 to 360 ms. The lines cross near 5,000 customers; the planner switches to hash join at about 1,700.

CustomersOrders joinedNested loopHash joinMerge joinPlanner’s pick
1002,4460.6 ms288 ms0.6 msnested loop
1,00024,89968 ms381 ms58 msnested loop
2,00050,076126 ms312 ms118 mshash join
5,000125,075311 ms306 ms297 mshash join
10,000250,098589 ms323 ms604 mshash join
20,000500,2841,219 ms360 ms1,451 mshash join
50,0001,250,5703,014 ms450 ms3,083 mshash join
200,0005,000,00013,451 ms863 ms12,511 mshash join

The two cost shapes from the plans explain the whole table. Nested loop pays per customer, about 0.06 ms for each one’s index lookup and page reads, so its line is straight. Hash join pays mostly for one scan of the orders table, so its line is flat. They cross near 5,000 customers, which is 125,000 orders, 2.5 percent of the table. At 200,000 customers the nested loop does 200,000 lookups to fetch every row of a table that a single scan reads in under a second.

Merge join tracks nested loop here, and for the same reason. To get orders in customer_id order it walks the same index, fetching scattered pages one at a time. It came out slightly ahead between 1,000 and 4,000 customers, probably because it walks the index once instead of starting a new search for each customer, and it stops as soon as the customer side runs out, so it never reads the rest of the orders index.

The planner is the surprise. It switched to hash join at 1,721 customers, when its own estimate reached about 1,695, and at 2,000 customers that choice cost 312 ms against 126 ms for the nested loop it rejected. It had the rows right. What it overpriced was the pages: the default random_page_cost = 4 tells it that a page fetched by an index lookup costs four times as much as a page read by a sequential scan, which is roughly right when the page has to come from a disk. In these runs every page was already in memory, so the lookups were much cheaper than the planner assumed.

The crossover moves with the cache
The orders table is 640 MB, which fits in the container’s 4 GB but not in PostgreSQL’s 128 MB shared_buffers, so most pages came from the operating system’s cache rather than disk. The jump in nested loop time from 300 customers (1.9 ms) to 1,000 (68 ms) is the point where the pages it touches stop fitting in shared_buffers: the nested loop plan above shows 19,558 pages read from outside it. On a server where those reads go to disk, every nested loop lookup costs more, the crossover moves toward fewer customers, and the planner’s early switch looks much better. Lowering random_page_cost toward 1.1 is the usual advice for SSDs, though the setting applies to every plan on the server at once.

That miss cost about two and a half times the time at 2,000 customers. A wrong row estimate errs in the other direction: it can keep a nested loop running long past the line. The PostgreSQL EXPLAIN post showed one source of wrong estimates in this data: the planner treats country and city as unrelated, so it multiplies their selectivities, the fractions of rows each filter keeps. Every city belongs to exactly one country, so the second filter removes nothing the first did not already remove. Here are customers in four countries and those countries’ 40 cities, 16,000 customers in total:

1
2
3
4
5
SELECT count(*), sum(o.total)
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.country IN ('C00','C01','C02','C03')
  AND c.city IN ('C00-0','C00-1', /* ...all 40 cities of those countries... */ 'C03-9');
1
2
3
4
5
6
7
 Nested Loop  (cost=0.53..122891.10 rows=32325 width=6) (actual time=0.043..1090.030 rows=399572.00 loops=1)
   Buffers: shared hit=119775 read=329022
   ->  Seq Scan on customers c  (cost=0.10..5274.10 rows=1293 width=4) (actual time=0.019..15.588 rows=16000.00 loops=1)
         Rows Removed by Filter: 184000
   ->  Index Scan using orders_customer_id_idx on orders o  (cost=0.43..90.70 rows=26 width=10) (actual time=0.004..0.066 rows=24.97 loops=16000)
         Index Cond: (customer_id = c.id)
 Execution Time: 1109.897 ms

The planner expected 1,293 customers, under its 1,700 switch point, and picked a nested loop. It got 16,000, and the inner side ran 16,000 times. The tell is on the outer side: estimated rows=1293, actual rows=16000.00, and an inner side whose loops matches the actual figure, not the estimate.

Bar chart: the planner’s nested loop took 999 ms; a forced hash join took 375 ms; after CREATE STATISTICS the planner chose the hash join itself at 376 ms.

CountriesEstimated customersActual customersPlanner’s pickNested loopHash join
23338,000nested loop598 ms373 ms
373512,000nested loop855 ms344 ms
41,29316,000nested loop999 ms375 ms
52,00120,000hash join1,206 ms358 ms
62,87324,000hash join1,442 ms380 ms

The table is the crossover chart again, seen through the estimate. At two to four countries the estimate stays under about 1,700 and the planner keeps the nested loop, up to 2.7 times slower than the hash join. At five countries the estimate crosses 1,700, and the planner switches even though the estimate is still ten times too low. A wrong estimate costs nothing while it stays on the right side of the line.

The fix is to tell the planner the columns are related:

1
2
CREATE STATISTICS customers_country_city (dependencies) ON country, city FROM customers;
ANALYZE customers;

With that, the estimate for four countries went from 1,293 to 15,820 customers, the planner picked the hash join on its own, and the query took 376 ms. MySQL has no equivalent for two columns that share no index (its histograms cover one column each), and with an index on the join column it would not change the join method anyway, for the reason in the MySQL section below.

The quicker reaction to a slow Nested Loop is to turn nested loops off and see what happens. That is a good experiment and a bad fix, because the setting changes every query on the connection, and a nested loop that was slow for 16,000 rows is still the right plan for 16. Christophe Pettus puts it plainly (July 2026): “Diagnose with the switch, then fix the statistics and set it back.”

Turning off nested loops is a test, not a fix
SET enable_nestloop = off in one session is the quickest way to check whether a hash join would be faster. Leaving it off in postgresql.conf, or in a connection pool’s startup settings, makes every small lookup join in the application pay for a full table scan. With only nested loops off, the 100-customer query took 237 ms as a hash join instead of 0.6 ms. Fix the estimate, then put the setting back.

The other obvious fix is memory. Batches above 1 under a Hash node means the hash table spilled to temporary files, as it did in the shipments join, and the natural response is to give it room. I raised work_mem from 1 MB to 1 GB on that join with the hash join forced, and did the same with MySQL’s join_buffer_size:

Two bar charts: in PostgreSQL the hash join took 1,446 ms at 1 MB of work_mem with 128 batches, rising to 2,425 ms at 1 GB with 1 batch; in MySQL it took 1,417 ms at 4 MB with 128 temp files and 3,349 ms at 1 GB in memory.

PostgreSQL work_memBatchesHash joinMySQL join_buffer_sizeTemp filesHash join
1 MB1281,446 ms256 KB (default)2561,893 ms
4 MB (default)641,503 ms4 MB1281,417 ms
64 MB42,286 ms64 MB83,076 ms
1 GB12,425 ms1 GB03,349 ms

On both engines, the join got slower as it got more memory. In PostgreSQL the single in-memory hash table peaked at 297,571 kB, and the query took 60 percent longer than with 64 small batches. In MySQL the fully in-memory join took more than twice as long as the run that wrote 128 temporary files. I repeated the PostgreSQL runs in a different order, ten runs per setting in all, and the ranking held.

The explanation that fits is the CPU cache. Each probe of a hash table lands at a random spot, and a 290 MB table is far larger than the processor’s cache, so nearly every probe waits on main memory, while a batch of a few megabytes stays in cache for as long as it is being probed. I did not measure cache misses, so treat that as the likely reason rather than a proven one. There is also a caveat that cuts the other way: the temporary files here never reached a disk, since the container had memory to spare and the operating system kept them in its cache. On a server short of memory, where those writes really hit storage, batching costs more.

Small spills still cost something. MySQL’s default 256 KB join buffer is tiny: in the customers-and-orders join it spilled from 8,000 customers on, with 24 temporary files, and took 804 ms against 549 ms with a 4 MB buffer. So a few megabytes helped and a gigabyte hurt. Batches: 64 in a plan says the hash table spilled, which by itself says little about why a query is slow, and Part IV of the queries series covers why raising work_mem server-wide is risky anyway.

MySQL has two of the three, and picks one

MySQL added hash joins in 8.0.18, and in 8.0.20 they replaced the block nested loop, the older method for joining without an index. There is no merge join. The 8.4 manual says when the hash join is used: “for any query for which each join has an equi-join condition, and in which there are no indexes that can be applied to any join conditions.” With an index on orders.customer_id, the optimizer chose the nested loop at every size I tried. To get a hash join I had to hide the index with hints:

1
2
3
SELECT /*+ JOIN_ORDER(c, o) NO_INDEX(o orders_customer_id_idx) */ count(*), sum(o.total)
FROM customers c JOIN orders o ON o.customer_id = c.id
WHERE c.id <= 20000;

Line chart: MySQL’s nested loop rises from near zero to 2,142 ms at 20,000 customers while a forced hash join stays near 550 ms, stepping up to about 830 ms once its hash table spills at 8,000 customers. The lines cross near 5,000.

CustomersNested loop (optimizer’s pick)Hash join (forced)
1,000121 ms548 ms
5,000584 ms558 ms
8,000913 ms829 ms
20,0002,142 ms836 ms
50,0001,084 ms869 ms
200,0002,876 ms1,237 ms

The crossover lands near 5,000 customers again, the same place as in PostgreSQL. The difference is that MySQL never makes the switch on its own. Both plans at 20,000 customers:

1
2
3
4
5
6
7
8
-> Nested loop inner join  (cost=990339 rows=981020) (actual time=0.21..2149 rows=500284 loops=1)
    -> Covering index range scan on c using PRIMARY over (id <= 20000)  (cost=8022 rows=39992) (actual time=0.148..2.01 rows=20000 loops=1)
    -> Index lookup on o using orders_customer_id_idx (customer_id=c.id)  (cost=22.1 rows=24.5) (actual time=0.099..0.107 rows=25 loops=20000)

-> Inner hash join (o.customer_id = c.id)  (cost=19.8e+9 rows=981020) (actual time=2.75..884 rows=500284 loops=1)
    -> Table scan on o  (cost=1.39 rows=4.96e+6) (actual time=0.0958..502 rows=5e+6 loops=1)
    -> Hash
        -> Covering index range scan on c using PRIMARY over (id <= 20000)  (cost=8022 rows=39992) (actual time=0.149..1.39 rows=20000 loops=1)

The cost MySQL printed for the hash join it was forced into is 19.8e+9, against 990,339 for the nested loop. Those are 20,000 times apart, and the runtimes go the other way, 884 ms against 2,149. Whatever the optimizer’s model of a hash join is, it is not one it uses to compete with an index.

What MySQL does change at larger sizes is the join order, which table goes on the outer side. From 50,000 customers on, it starts from the orders table, scanning all 5,000,000 rows once and looking up each order’s customer by primary key. That is still a nested loop, but its cost now grows with the orders scanned instead of the customers selected, which is why 50,000 customers ran faster than 20,000. At 200,000 customers it took 2,876 ms against 1,237 ms for the hash join. (MySQL’s optimizer, to its credit, is very consistent.)

For a reporting query that joins a large share of two big tables, the lever in MySQL 8.4 is an index hint like the one above. Merge join is not available, so the shipments join ran either as a nested loop (2,614 ms) or as a hash join (1,417 to 3,349 ms depending on the buffer).

What to check when a join is slow

Start by naming the join. Each algorithm leaves the same marks in every plan above:

AlgorithmPostgreSQL planMySQL plan
Nested loopNested Loop, with loops= on the inner child equal to the outer side’s rowsNested loop inner join, with an Index lookup child and its loops=
Hash joinHash Join and Hash Cond, over a Hash child showing Buckets, Batches and Memory UsageInner hash join (o.customer_id = c.id), over a Hash child
Merge joinMerge Join and Merge Cond, over two inputs in join-column orderdoes not exist

Then read its outer side. If estimated rows and actual rows differ by more than a few times, the planner may have chosen for a size it never got, and the inner side’s loops will match the actual number. In PostgreSQL, rerun the query in one session with SET enable_nestloop = off or enable_hashjoin = off. If the other plan is much faster, the algorithm choice was the problem, and the estimate is what to fix: ANALYZE, a higher statistics target on the column, or CREATE STATISTICS for columns that move together. In MySQL the same test is a NO_INDEX hint on the inner table, and with an index on the join column a hint is also the only way to get a hash join.

If the estimate is right and the join is still slow, look at how many rows go into it. A nested loop with 200,000 loops, or a hash join building on millions of rows, is often a query asking for more than the screen shows. Batches above 1 comes last: in these runs a few more megabytes helped and a gigabyte hurt.

SELECT insights FROM experience WHERE downtime = 0; -- Ruslan Tolkachev