<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Database Fundamentals on EXPLAIN ANALYZE</title><link>https://explainanalyze.com/categories/database-fundamentals/</link><description>Recent content in Database Fundamentals on EXPLAIN ANALYZE</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><atom:link href="https://explainanalyze.com/categories/database-fundamentals/index.xml" rel="self" type="application/rss+xml"/><item><title>Hash Join vs Merge Join vs Nested Loop: When Each One Wins</title><link>https://explainanalyze.com/p/hash-join-vs-merge-join-vs-nested-loop-when-each-one-wins/</link><pubDate>Tue, 10 Nov 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/hash-join-vs-merge-join-vs-nested-loop-when-each-one-wins/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Hash Join vs Merge Join vs Nested Loop: When Each One Wins" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;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&amp;rsquo;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.&lt;/div&gt;
&lt;/div&gt;

&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;&lt;/th&gt;
 &lt;th&gt;Nested loop&lt;/th&gt;
 &lt;th&gt;Hash join&lt;/th&gt;
 &lt;th&gt;Merge join&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;How it works&lt;/td&gt;
 &lt;td&gt;For each row of the first input, look up matching rows in the second&lt;/td&gt;
 &lt;td&gt;Load one input into a hash table in memory, then check every row of the other against it&lt;/td&gt;
 &lt;td&gt;Walk two inputs sorted on the join column side by side&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;What it needs&lt;/td&gt;
 &lt;td&gt;An index on the second table&amp;rsquo;s join column&lt;/td&gt;
 &lt;td&gt;Memory for the hash table (&lt;code&gt;work_mem&lt;/code&gt; in PostgreSQL, &lt;code&gt;join_buffer_size&lt;/code&gt; in MySQL)&lt;/td&gt;
 &lt;td&gt;Both inputs in join-column order, from an index or a sort&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Fastest here&lt;/td&gt;
 &lt;td&gt;Under about 5,000 customers: 68 ms against 381 ms for hash at 1,000, with merge join tied because it walks the same index&lt;/td&gt;
 &lt;td&gt;Past about 5,000: 360 ms against 1,219 ms for nested loop at 20,000&lt;/td&gt;
 &lt;td&gt;Two big tables in key order: 1,084 ms against 1,621 ms hash and 3,029 ms nested loop&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;How it fails&lt;/td&gt;
 &lt;td&gt;Cost grows with every outer row: 13.5 s at 200,000 customers, against 0.86 s for hash&lt;/td&gt;
 &lt;td&gt;Starts by reading the whole second table, so it took 268 ms even for 10 customers&lt;/td&gt;
 &lt;td&gt;Without an index to supply the order, a sort comes first&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;PostgreSQL 18&lt;/td&gt;
 &lt;td&gt;Chosen by cost&lt;/td&gt;
 &lt;td&gt;Chosen by cost&lt;/td&gt;
 &lt;td&gt;Chosen by cost&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;MySQL 8.4&lt;/td&gt;
 &lt;td&gt;Chosen whenever an index fits the join&lt;/td&gt;
 &lt;td&gt;Used only when no index fits the join&lt;/td&gt;
 &lt;td&gt;Does not exist&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;All of it is measured below except the sort in merge join&amp;rsquo;s failure row, which is documented behavior these tests never triggered.&lt;/p&gt;
&lt;p&gt;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&amp;rsquo;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&amp;rsquo;s planner, the part that picks how a query runs, estimates the rows each step will produce, prices the options and runs the cheapest.&lt;/p&gt;
&lt;h2 id="a-quick-reminder-about-query-plans"&gt;A quick reminder about query plans
&lt;/h2&gt;&lt;p&gt;The planner&amp;rsquo;s choice is printed in the query plan, the tree of steps the database runs to answer a query. &lt;code&gt;EXPLAIN&lt;/code&gt; shows the plan, and &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; 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: &lt;code&gt;rows&lt;/code&gt;, how many rows the planner expects it to produce, and &lt;code&gt;cost&lt;/code&gt;, 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 &lt;code&gt;rows&lt;/code&gt; against the actual ones, because the planner chose its join from the estimate; the how-tos for &lt;a class="link" href="https://explainanalyze.com/p/how-to-read-explain-analyze-postgresql/" &gt;PostgreSQL&lt;/a&gt; and &lt;a class="link" href="https://explainanalyze.com/p/how-to-read-explain-analyze-mysql/" &gt;MySQL&lt;/a&gt; cover the rest of the output (MySQL calls its planner the optimizer).&lt;/p&gt;
&lt;h2 id="three-ways-to-join-two-tables"&gt;Three ways to join two tables
&lt;/h2&gt;&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;orders.customer_id&lt;/code&gt;. A third table, &lt;code&gt;shipments&lt;/code&gt;, 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 &lt;code&gt;o.customer_id = c.id&lt;/code&gt;; 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.&lt;/p&gt;
&lt;p&gt;The query for most of this post totals the orders of the first N customers:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h3 id="nested-loop"&gt;Nested loop
&lt;/h3&gt;&lt;p&gt;A nested loop is the lookup from the outline, and what it needs is an index on the second input&amp;rsquo;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):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Nested Loop (cost=0.85..93322.60 rows=24625 width=6) (actual time=0.010..54.458 rows=24899.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=8343 read=19558
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; 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)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Index Cond: (id &amp;lt;= 1000)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; 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)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Index Cond: (customer_id = c.id)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Execution Time: 55.356 ms
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;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 (&lt;code&gt;loops=1000&lt;/code&gt;), and each run uses the index to find that customer&amp;rsquo;s 25 or so orders. So the work grows with the outer side: twice the customers, twice the index lookups. &lt;code&gt;Buffers&lt;/code&gt; counts pages, the 8 KB blocks PostgreSQL stores a table in, and this join touched 27,901 of them, because each customer&amp;rsquo;s orders are scattered across the table and almost every order lives on a different page.&lt;/p&gt;
&lt;h3 id="hash-join"&gt;Hash join
&lt;/h3&gt;&lt;p&gt;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:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Hash Join (cost=804.18..145898.06 rows=489805 width=6) (actual time=1.398..387.738 rows=500284.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Hash Cond: (o.customer_id = c.id)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; 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)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Hash (cost=559.28..559.28 rows=19592 width=4) (actual time=1.328..1.328 rows=20000.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buckets: 32768 Batches: 1 Memory Usage: 960kB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; 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)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Execution Time: 407.853 ms
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Read it from the bottom. The &lt;code&gt;Hash&lt;/code&gt; node collects the 20,000 customer ids into the hash table, which took 960 kB and a millisecond. Then &lt;code&gt;Seq Scan on orders&lt;/code&gt; reads all 5,000,000 orders once, and for each one the join checks the hash table for its &lt;code&gt;customer_id&lt;/code&gt;. Nothing runs more than once (&lt;code&gt;loops=1&lt;/code&gt; everywhere), and the index on &lt;code&gt;orders.customer_id&lt;/code&gt; 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 &lt;code&gt;work_mem&lt;/code&gt; in PostgreSQL and &lt;code&gt;join_buffer_size&lt;/code&gt; in MySQL, and &lt;code&gt;Batches: 1&lt;/code&gt; means the hash table fit inside it. That line comes back below.&lt;/p&gt;
&lt;h3 id="merge-join"&gt;Merge join
&lt;/h3&gt;&lt;p&gt;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 &lt;code&gt;shipments&lt;/code&gt; join can, because both it and &lt;code&gt;orders&lt;/code&gt; have primary keys on the order id:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;weight_g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Merge Join (cost=12.71..366817.13 rows=4950090 width=4) (actual time=0.017..967.736 rows=4950073.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Merge Cond: (o.id = s.order_id)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; 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)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; 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)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Execution Time: 1083.770 ms
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;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 &lt;code&gt;Merge Cond&lt;/code&gt; line gives it away.&lt;/p&gt;
&lt;p&gt;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:&lt;/p&gt;
&lt;p&gt;&lt;img alt="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." loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://explainanalyze.com/p/hash-join-vs-merge-join-vs-nested-loop-when-each-one-wins/pg-big-join.svg"&gt;&lt;/p&gt;
&lt;p&gt;The hash join has to load 4,950,073 shipments into its hash table before it can match anything, and at the default &lt;code&gt;work_mem&lt;/code&gt; that table did not fit, so PostgreSQL split it into 64 batches written to temporary files (&lt;code&gt;Batches: 64&lt;/code&gt; under the &lt;code&gt;Hash&lt;/code&gt; 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.&lt;/p&gt;
&lt;h2 id="where-the-choice-goes-wrong-and-two-fixes-that-backfire"&gt;Where the choice goes wrong, and two fixes that backfire
&lt;/h2&gt;&lt;p&gt;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 &lt;code&gt;enable_hashjoin&lt;/code&gt;, &lt;code&gt;enable_mergejoin&lt;/code&gt; and &lt;code&gt;enable_nestloop&lt;/code&gt; set to &lt;code&gt;off&lt;/code&gt; for the session.&lt;/p&gt;
&lt;p&gt;&lt;img alt="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." loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://explainanalyze.com/p/hash-join-vs-merge-join-vs-nested-loop-when-each-one-wins/pg-crossover.svg"&gt;&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th style="text-align: right"&gt;Customers&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Orders joined&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Nested loop&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Hash join&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Merge join&lt;/th&gt;
 &lt;th&gt;Planner&amp;rsquo;s pick&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;100&lt;/td&gt;
 &lt;td style="text-align: right"&gt;2,446&lt;/td&gt;
 &lt;td style="text-align: right"&gt;0.6 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;288 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;0.6 ms&lt;/td&gt;
 &lt;td&gt;nested loop&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;1,000&lt;/td&gt;
 &lt;td style="text-align: right"&gt;24,899&lt;/td&gt;
 &lt;td style="text-align: right"&gt;68 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;381 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;58 ms&lt;/td&gt;
 &lt;td&gt;nested loop&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;2,000&lt;/td&gt;
 &lt;td style="text-align: right"&gt;50,076&lt;/td&gt;
 &lt;td style="text-align: right"&gt;126 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;312 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;118 ms&lt;/td&gt;
 &lt;td&gt;hash join&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;5,000&lt;/td&gt;
 &lt;td style="text-align: right"&gt;125,075&lt;/td&gt;
 &lt;td style="text-align: right"&gt;311 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;306 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;297 ms&lt;/td&gt;
 &lt;td&gt;hash join&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;10,000&lt;/td&gt;
 &lt;td style="text-align: right"&gt;250,098&lt;/td&gt;
 &lt;td style="text-align: right"&gt;589 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;323 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;604 ms&lt;/td&gt;
 &lt;td&gt;hash join&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;20,000&lt;/td&gt;
 &lt;td style="text-align: right"&gt;500,284&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,219 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;360 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,451 ms&lt;/td&gt;
 &lt;td&gt;hash join&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;50,000&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,250,570&lt;/td&gt;
 &lt;td style="text-align: right"&gt;3,014 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;450 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;3,083 ms&lt;/td&gt;
 &lt;td&gt;hash join&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;200,000&lt;/td&gt;
 &lt;td style="text-align: right"&gt;5,000,000&lt;/td&gt;
 &lt;td style="text-align: right"&gt;13,451 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;863 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;12,511 ms&lt;/td&gt;
 &lt;td&gt;hash join&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The two cost shapes from the plans explain the whole table. Nested loop pays per customer, about 0.06 ms for each one&amp;rsquo;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.&lt;/p&gt;
&lt;p&gt;Merge join tracks nested loop here, and for the same reason. To get orders in &lt;code&gt;customer_id&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;random_page_cost = 4&lt;/code&gt; 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.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;The crossover moves with the cache&lt;/strong&gt;
 &lt;div&gt;The orders table is 640 MB, which fits in the container&amp;rsquo;s 4 GB but not in PostgreSQL&amp;rsquo;s 128 MB &lt;code&gt;shared_buffers&lt;/code&gt;, so most pages came from the operating system&amp;rsquo;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 &lt;code&gt;shared_buffers&lt;/code&gt;: 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&amp;rsquo;s early switch looks much better. Lowering &lt;code&gt;random_page_cost&lt;/code&gt; toward 1.1 is the usual advice for SSDs, though the setting applies to every plan on the server at once.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;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 &lt;a class="link" href="https://explainanalyze.com/p/how-to-read-explain-analyze-postgresql/" &gt;PostgreSQL EXPLAIN post&lt;/a&gt; showed one source of wrong estimates in this data: the planner treats &lt;code&gt;country&lt;/code&gt; and &lt;code&gt;city&lt;/code&gt; 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&amp;rsquo; 40 cities, 16,000 customers in total:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;C00&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;C01&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;C02&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;C03&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;C00-0&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;C00-1&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cm"&gt;/* ...all 40 cities of those countries... */&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;C03-9&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Nested Loop (cost=0.53..122891.10 rows=32325 width=6) (actual time=0.043..1090.030 rows=399572.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=119775 read=329022
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; 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)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Rows Removed by Filter: 184000
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; 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)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Index Cond: (customer_id = c.id)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Execution Time: 1109.897 ms
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;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 &lt;code&gt;rows=1293&lt;/code&gt;, actual &lt;code&gt;rows=16000.00&lt;/code&gt;, and an inner side whose &lt;code&gt;loops&lt;/code&gt; matches the actual figure, not the estimate.&lt;/p&gt;
&lt;p&gt;&lt;img alt="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." loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://explainanalyze.com/p/hash-join-vs-merge-join-vs-nested-loop-when-each-one-wins/pg-misestimate.svg"&gt;&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th style="text-align: right"&gt;Countries&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Estimated customers&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Actual customers&lt;/th&gt;
 &lt;th&gt;Planner&amp;rsquo;s pick&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Nested loop&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Hash join&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;2&lt;/td&gt;
 &lt;td style="text-align: right"&gt;333&lt;/td&gt;
 &lt;td style="text-align: right"&gt;8,000&lt;/td&gt;
 &lt;td&gt;nested loop&lt;/td&gt;
 &lt;td style="text-align: right"&gt;598 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;373 ms&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;3&lt;/td&gt;
 &lt;td style="text-align: right"&gt;735&lt;/td&gt;
 &lt;td style="text-align: right"&gt;12,000&lt;/td&gt;
 &lt;td&gt;nested loop&lt;/td&gt;
 &lt;td style="text-align: right"&gt;855 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;344 ms&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;4&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,293&lt;/td&gt;
 &lt;td style="text-align: right"&gt;16,000&lt;/td&gt;
 &lt;td&gt;nested loop&lt;/td&gt;
 &lt;td style="text-align: right"&gt;999 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;375 ms&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;5&lt;/td&gt;
 &lt;td style="text-align: right"&gt;2,001&lt;/td&gt;
 &lt;td style="text-align: right"&gt;20,000&lt;/td&gt;
 &lt;td&gt;hash join&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,206 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;358 ms&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;6&lt;/td&gt;
 &lt;td style="text-align: right"&gt;2,873&lt;/td&gt;
 &lt;td style="text-align: right"&gt;24,000&lt;/td&gt;
 &lt;td&gt;hash join&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,442 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;380 ms&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;The fix is to tell the planner the columns are related:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;STATISTICS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers_country_city&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dependencies&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;The quicker reaction to a slow &lt;code&gt;Nested Loop&lt;/code&gt; 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 &lt;a class="link" href="https://thebuild.com/blog/all-your-gucs-in-a-row-enable_nestloop/" target="_blank" rel="noopener"
 &gt;puts it plainly&lt;/a&gt; (July 2026): &amp;ldquo;Diagnose with the switch, then fix the statistics and set it back.&amp;rdquo;&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Turning off nested loops is a test, not a fix&lt;/strong&gt;
 &lt;div&gt;&lt;code&gt;SET enable_nestloop = off&lt;/code&gt; in one session is the quickest way to check whether a hash join would be faster. Leaving it off in &lt;code&gt;postgresql.conf&lt;/code&gt;, or in a connection pool&amp;rsquo;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.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The other obvious fix is memory. &lt;code&gt;Batches&lt;/code&gt; above 1 under a &lt;code&gt;Hash&lt;/code&gt; 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 &lt;code&gt;work_mem&lt;/code&gt; from 1 MB to 1 GB on that join with the hash join forced, and did the same with MySQL&amp;rsquo;s &lt;code&gt;join_buffer_size&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img alt="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." loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://explainanalyze.com/p/hash-join-vs-merge-join-vs-nested-loop-when-each-one-wins/hash-memory.svg"&gt;&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;PostgreSQL &lt;code&gt;work_mem&lt;/code&gt;&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Batches&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Hash join&lt;/th&gt;
 &lt;th&gt;MySQL &lt;code&gt;join_buffer_size&lt;/code&gt;&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Temp files&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Hash join&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;1 MB&lt;/td&gt;
 &lt;td style="text-align: right"&gt;128&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,446 ms&lt;/td&gt;
 &lt;td&gt;256 KB (default)&lt;/td&gt;
 &lt;td style="text-align: right"&gt;256&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,893 ms&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;4 MB (default)&lt;/td&gt;
 &lt;td style="text-align: right"&gt;64&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,503 ms&lt;/td&gt;
 &lt;td&gt;4 MB&lt;/td&gt;
 &lt;td style="text-align: right"&gt;128&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,417 ms&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;64 MB&lt;/td&gt;
 &lt;td style="text-align: right"&gt;4&lt;/td&gt;
 &lt;td style="text-align: right"&gt;2,286 ms&lt;/td&gt;
 &lt;td&gt;64 MB&lt;/td&gt;
 &lt;td style="text-align: right"&gt;8&lt;/td&gt;
 &lt;td style="text-align: right"&gt;3,076 ms&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;1 GB&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1&lt;/td&gt;
 &lt;td style="text-align: right"&gt;2,425 ms&lt;/td&gt;
 &lt;td&gt;1 GB&lt;/td&gt;
 &lt;td style="text-align: right"&gt;0&lt;/td&gt;
 &lt;td style="text-align: right"&gt;3,349 ms&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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&amp;rsquo;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.&lt;/p&gt;
&lt;p&gt;Small spills still cost something. MySQL&amp;rsquo;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. &lt;code&gt;Batches: 64&lt;/code&gt; in a plan says the hash table spilled, which by itself says little about why a query is slow, and &lt;a class="link" href="https://explainanalyze.com/p/its-almost-always-the-queries-part-iv-when-the-sort-spills/" &gt;Part IV of the queries series&lt;/a&gt; covers why raising &lt;code&gt;work_mem&lt;/code&gt; server-wide is risky anyway.&lt;/p&gt;
&lt;h2 id="mysql-has-two-of-the-three-and-picks-one"&gt;MySQL has two of the three, and picks one
&lt;/h2&gt;&lt;p&gt;MySQL added hash joins in 8.0.18, and in 8.0.20 they &lt;a class="link" href="https://dev.mysql.com/doc/relnotes/mysql/8.0/en/news-8-0-20.html" target="_blank" rel="noopener"
 &gt;replaced the block nested loop&lt;/a&gt;, the older method for joining without an index. There is no merge join. The &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.4/en/hash-joins.html" target="_blank" rel="noopener"
 &gt;8.4 manual&lt;/a&gt; says when the hash join is used: &amp;ldquo;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.&amp;rdquo; With an index on &lt;code&gt;orders.customer_id&lt;/code&gt;, the optimizer chose the nested loop at every size I tried. To get a hash join I had to hide the index with hints:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cm"&gt;/*+ JOIN_ORDER(c, o) NO_INDEX(o orders_customer_id_idx) */&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;img alt="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." loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://explainanalyze.com/p/hash-join-vs-merge-join-vs-nested-loop-when-each-one-wins/my-crossover.svg"&gt;&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th style="text-align: right"&gt;Customers&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Nested loop (optimizer&amp;rsquo;s pick)&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Hash join (forced)&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;1,000&lt;/td&gt;
 &lt;td style="text-align: right"&gt;121 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;548 ms&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;5,000&lt;/td&gt;
 &lt;td style="text-align: right"&gt;584 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;558 ms&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;8,000&lt;/td&gt;
 &lt;td style="text-align: right"&gt;913 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;829 ms&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;20,000&lt;/td&gt;
 &lt;td style="text-align: right"&gt;2,142 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;836 ms&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;50,000&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,084 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;869 ms&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: right"&gt;200,000&lt;/td&gt;
 &lt;td style="text-align: right"&gt;2,876 ms&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,237 ms&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;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:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Nested loop inner join (cost=990339 rows=981020) (actual time=0.21..2149 rows=500284 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Covering index range scan on c using PRIMARY over (id &amp;lt;= 20000) (cost=8022 rows=39992) (actual time=0.148..2.01 rows=20000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; 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)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Inner hash join (o.customer_id = c.id) (cost=19.8e+9 rows=981020) (actual time=2.75..884 rows=500284 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on o (cost=1.39 rows=4.96e+6) (actual time=0.0958..502 rows=5e+6 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Hash
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Covering index range scan on c using PRIMARY over (id &amp;lt;= 20000) (cost=8022 rows=39992) (actual time=0.149..1.39 rows=20000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;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&amp;rsquo;s model of a hash join is, it is not one it uses to compete with an index.&lt;/p&gt;
&lt;p&gt;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&amp;rsquo;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&amp;rsquo;s optimizer, to its credit, is very consistent.)&lt;/p&gt;
&lt;p&gt;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).&lt;/p&gt;
&lt;h2 id="what-to-check-when-a-join-is-slow"&gt;What to check when a join is slow
&lt;/h2&gt;&lt;p&gt;Start by naming the join. Each algorithm leaves the same marks in every plan above:&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Algorithm&lt;/th&gt;
 &lt;th&gt;PostgreSQL plan&lt;/th&gt;
 &lt;th&gt;MySQL plan&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;Nested loop&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;Nested Loop&lt;/code&gt;, with &lt;code&gt;loops=&lt;/code&gt; on the inner child equal to the outer side&amp;rsquo;s rows&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;Nested loop inner join&lt;/code&gt;, with an &lt;code&gt;Index lookup&lt;/code&gt; child and its &lt;code&gt;loops=&lt;/code&gt;&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Hash join&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;Hash Join&lt;/code&gt; and &lt;code&gt;Hash Cond&lt;/code&gt;, over a &lt;code&gt;Hash&lt;/code&gt; child showing &lt;code&gt;Buckets&lt;/code&gt;, &lt;code&gt;Batches&lt;/code&gt; and &lt;code&gt;Memory Usage&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;Inner hash join (o.customer_id = c.id)&lt;/code&gt;, over a &lt;code&gt;Hash&lt;/code&gt; child&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Merge join&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;Merge Join&lt;/code&gt; and &lt;code&gt;Merge Cond&lt;/code&gt;, over two inputs in join-column order&lt;/td&gt;
 &lt;td&gt;does not exist&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;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&amp;rsquo;s &lt;code&gt;loops&lt;/code&gt; will match the actual number. In PostgreSQL, rerun the query in one session with &lt;code&gt;SET enable_nestloop = off&lt;/code&gt; or &lt;code&gt;enable_hashjoin = off&lt;/code&gt;. If the other plan is much faster, the algorithm choice was the problem, and the estimate is what to fix: &lt;code&gt;ANALYZE&lt;/code&gt;, a higher statistics target on the column, or &lt;code&gt;CREATE STATISTICS&lt;/code&gt; for columns that move together. In MySQL the same test is a &lt;code&gt;NO_INDEX&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;loops&lt;/code&gt;, or a hash join building on millions of rows, is often a query asking for more than the screen shows. &lt;code&gt;Batches&lt;/code&gt; above 1 comes last: in these runs a few more megabytes helped and a gigabyte hurt.&lt;/p&gt;</description></item><item><title>Clustered vs Non-Clustered Index: The Table Is the Index in MySQL</title><link>https://explainanalyze.com/p/clustered-vs-non-clustered-index-the-table-is-the-index-in-mysql/</link><pubDate>Mon, 09 Nov 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/clustered-vs-non-clustered-index-the-table-is-the-index-in-mysql/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Clustered vs Non-Clustered Index: The Table Is the Index in MySQL" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;A clustered index decides the order in which a table&amp;rsquo;s rows are stored on disk, and with it how many reads a query for related rows has to make. This post covers how InnoDB, MySQL&amp;rsquo;s storage engine, lays out a table, what every other (non-clustered) index costs and how it finds a row, and why the order of the primary key decides the reads. Then it measures what keeping related rows together costs on every insert, how much free room a rebuild can leave for new rows, and how PostgreSQL, which has no clustered index, gets close, on MySQL 8.4.13 and PostgreSQL 18.6.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The comparison first, on a clients-and-assets schema where each client owns about 50 assets. Every term in it is built up in the sections below, and every cell is measured there:&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;&lt;/th&gt;
 &lt;th&gt;MySQL, clustered on &lt;code&gt;asset_id&lt;/code&gt;&lt;/th&gt;
 &lt;th&gt;MySQL, clustered on &lt;code&gt;(client_id, asset_id)&lt;/code&gt;&lt;/th&gt;
 &lt;th&gt;PostgreSQL heap&lt;/th&gt;
 &lt;th&gt;PostgreSQL after &lt;code&gt;CLUSTER&lt;/code&gt;&lt;/th&gt;
 &lt;th&gt;PostgreSQL covering index&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;One client&amp;rsquo;s assets, disk limited to 3,000 reads/s&lt;/td&gt;
 &lt;td&gt;15.0 ms, 45 pages&lt;/td&gt;
 &lt;td&gt;0.53 ms, 1.7 pages&lt;/td&gt;
 &lt;td&gt;14.8 ms, 46 pages&lt;/td&gt;
 &lt;td&gt;1.02 ms, 3.1 pages&lt;/td&gt;
 &lt;td&gt;0.60 ms, 1.7 pages&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Same, after 30% more rows arrived&lt;/td&gt;
 &lt;td&gt;20.4 ms&lt;/td&gt;
 &lt;td&gt;1.07 ms&lt;/td&gt;
 &lt;td&gt;not run&lt;/td&gt;
 &lt;td&gt;5.22 ms&lt;/td&gt;
 &lt;td&gt;not run&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;One asset by &lt;code&gt;asset_id&lt;/code&gt;, pages touched&lt;/td&gt;
 &lt;td&gt;4.0&lt;/td&gt;
 &lt;td&gt;8.4&lt;/td&gt;
 &lt;td&gt;4.0&lt;/td&gt;
 &lt;td&gt;not run&lt;/td&gt;
 &lt;td&gt;not run&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Inserting the next 1M assets&lt;/td&gt;
 &lt;td&gt;34.5 s&lt;/td&gt;
 &lt;td&gt;105.7 s&lt;/td&gt;
 &lt;td&gt;not compared&lt;/td&gt;
 &lt;td&gt;not compared&lt;/td&gt;
 &lt;td&gt;not compared&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Space it costs&lt;/td&gt;
 &lt;td&gt;baseline&lt;/td&gt;
 &lt;td&gt;2.2x the default table after inserts&lt;/td&gt;
 &lt;td&gt;baseline&lt;/td&gt;
 &lt;td&gt;none&lt;/td&gt;
 &lt;td&gt;a 398 MB index instead of 34 MB&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Rows stay in client order&lt;/td&gt;
 &lt;td&gt;no&lt;/td&gt;
 &lt;td&gt;always&lt;/td&gt;
 &lt;td&gt;no&lt;/td&gt;
 &lt;td&gt;only until new rows arrive&lt;/td&gt;
 &lt;td&gt;the index always does&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;PostgreSQL&amp;rsquo;s insert times are left out, because two engines inserting through different code paths is not a fair race. The comparison that matters there is MySQL against itself, with only the primary key changed.&lt;/p&gt;
&lt;p&gt;So here is how InnoDB stores a table, in outline. The simplest way to build a table is to put each new row wherever there is room, usually at the end of a file, and keep every index beside it as a sorted list of values, each with the address of its row. Inserts are cheap that way, and every index works alike. The catch shows up once the table is bigger than memory. Rows that a query wants together (all the assets of one client, say) arrived at different times and were written to different places, so each one the index finds can be a separate read from disk. PostgreSQL stores its tables like that, in what it calls a heap. InnoDB, the storage engine MySQL uses by default, made the other choice: it stores the rows inside the primary key&amp;rsquo;s index, sorted by the primary key, so rows whose keys are close sit next to each other on disk. That index is the clustered index. Because rows can be kept in only one order, a table gets one, and every other index stores a primary key value where PostgreSQL&amp;rsquo;s would store an address. Choosing the primary key is choosing which rows share a place on disk.&lt;/p&gt;
&lt;h2 id="a-quick-reminder-about-indexes-and-pages"&gt;A quick reminder about indexes and pages
&lt;/h2&gt;&lt;p&gt;An index is a sorted copy of one or more columns that lets the database find rows without reading the whole table. MySQL and PostgreSQL both build theirs as B-trees: a tree of pages, where the top page says which page below it holds which range of values, down to the bottom pages (the leaves) that hold the entries themselves. A page is the unit the database reads from disk, 16 KB in InnoDB and 8 KB in PostgreSQL, and reading one row means reading the whole page it is on. So the number of pages a query touches is most of what it costs, and it is the number this post counts.&lt;/p&gt;
&lt;p&gt;The term clustered index comes from SQL Server, where it names an index whose leaves are the table&amp;rsquo;s rows instead of pointers to them. Here is what that means for one client&amp;rsquo;s assets in each engine. The left half is what the rest of the post builds up, one piece at a time:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Left: in MySQL InnoDB the primary key tree holds the rows themselves, so one client’s 50 assets sit on one or two leaf pages, and a secondary index on asset_id stores the primary key and searches the tree again. Right: in PostgreSQL an index on client_id points to 50 page-and-position addresses scattered across the heap." loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://explainanalyze.com/p/clustered-vs-non-clustered-index-the-table-is-the-index-in-mysql/storage-layout.svg"&gt;&lt;/p&gt;
&lt;h2 id="the-rows-live-inside-the-primary-key"&gt;The rows live inside the primary key
&lt;/h2&gt;&lt;p&gt;In InnoDB the primary key does not point at the rows, because its leaf pages are the rows. Here is the table most people would write first, with an auto-increment &lt;code&gt;asset_id&lt;/code&gt; as the primary key and an index on &lt;code&gt;client_id&lt;/code&gt; for looking up a client&amp;rsquo;s assets:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;assets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;asset_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;asset_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;serial&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;acquired_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;assets_client_id_idx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.4/en/innodb-index-types.html" target="_blank" rel="noopener"
 &gt;MySQL manual&lt;/a&gt; says it directly: &amp;ldquo;Each InnoDB table has a special index called the clustered index that stores row data. Typically, the clustered index is synonymous with the primary key.&amp;rdquo; In B-tree terms, the leaf pages of the primary key&amp;rsquo;s tree hold whole rows, sorted by &lt;code&gt;asset_id&lt;/code&gt;, and the pages above them hold only enough of the key to find the right leaf. A new asset gets the next id, so it goes onto the last leaf page, and the table ends up in arrival order. That keeps inserts cheap. It also means that client 621&amp;rsquo;s 50 assets, added at 50 different moments over the years, sit on 50 different pages, each next to whatever other clients&amp;rsquo; assets arrived around the same time.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;A MySQL table always has a clustered index&lt;/strong&gt;
 &lt;div&gt;Leave out the primary key and InnoDB picks one anyway. It uses the first &lt;code&gt;UNIQUE&lt;/code&gt; index whose columns are all &lt;code&gt;NOT NULL&lt;/code&gt;, and if there is none, it generates a hidden 6-byte row id that &amp;ldquo;increases monotonically as new rows are inserted&amp;rdquo;, per the same manual page. The rows are still clustered, just by an order nobody chose.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The index on &lt;code&gt;client_id&lt;/code&gt; is the other kind. A non-clustered index, which MySQL calls a secondary index, is a B-tree of its own, and the manual describes what its leaves hold: &amp;ldquo;each record in a secondary index contains the primary key columns for the row, as well as the columns specified for the secondary index.&amp;rdquo; So it does not know where a row is. It knows the row&amp;rsquo;s primary key, and getting from there to the row takes a second search, down the clustered index.&lt;/p&gt;
&lt;p&gt;To see what those two searches cost, I loaded both engines with the same data: MySQL 8.4.13 and PostgreSQL 18.6 in Docker with default settings. A &lt;code&gt;clients&lt;/code&gt; table has 100,000 rows. An &lt;code&gt;assets&lt;/code&gt; table has 5,000,000, each belonging to a random client, so a client&amp;rsquo;s assets arrive scattered over time, the way real data grows. The timed runs fetch the assets of 1,000 random clients with the container held to 1 GB of memory and 3,000 disk reads per second, roughly the baseline of an AWS gp3 volume, so that pages really come from disk. Page counts are averages over the same 1,000 clients. The test tables were loaded with &lt;code&gt;asset_id&lt;/code&gt; values already assigned in increasing order, the order an &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; column produces, and the plans below name the test copies (&lt;code&gt;a_by_id&lt;/code&gt;, &lt;code&gt;a_by_client&lt;/code&gt;, &lt;code&gt;assets_orig&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Now fetch one client&amp;rsquo;s assets:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;asset_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;asset_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;assets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;621&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Index lookup on a_by_id using assets_client_id_idx (client_id=621) (cost=55 rows=50) (actual time=0.0522..0.181 rows=50 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The plan names one step, and both searches are hiding inside it. The secondary index&amp;rsquo;s entries for client 621 are 50 pairs of &lt;code&gt;(621, asset_id)&lt;/code&gt;. To get &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;value&lt;/code&gt;, InnoDB takes each &lt;code&gt;asset_id&lt;/code&gt; and searches the clustered index for it: 50 separate searches, ending on 50 different pages. Averaged over 1,000 clients, that was 45 pages read from disk per lookup, for 50 rows.&lt;/p&gt;
&lt;p&gt;PostgreSQL ends up in the same place by a different road. Its table is a heap, and its index on &lt;code&gt;client_id&lt;/code&gt; holds row addresses instead of keys:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Bitmap Heap Scan on assets_orig (cost=4.83..206.34 rows=51 width=47) (actual time=1.328..7.858 rows=50.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Recheck Cond: (client_id = 621)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Heap Blocks: exact=50
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=8 read=45
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Bitmap Index Scan on assets_orig_client_id_idx (cost=0.00..4.82 rows=51 width=0) (actual time=0.703..0.704 rows=50.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Index Cond: (client_id = 621)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared read=3
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;Heap Blocks: exact=50&lt;/code&gt; says the 50 addresses pointed to 50 different heap pages, and &lt;code&gt;read=45&lt;/code&gt; is the pages that had to come from outside PostgreSQL&amp;rsquo;s memory. The heap and the auto-increment clustered index have the same problem: both keep rows in arrival order, and this query wants them in client order. The difference is that InnoDB lets you pick the order. Sort the clustered index by client instead, and the same 50 assets sit together:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Two rows of pages. Top, clustered on asset_id: rows in arrival order, so client 621’s assets sit one per page across every page. Bottom, clustered on client_id then asset_id: each client’s rows fill their own run of pages, and all of client 621’s assets sit on one or two pages." loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://explainanalyze.com/p/clustered-vs-non-clustered-index-the-table-is-the-index-in-mysql/page-grouping.svg"&gt;&lt;/p&gt;
&lt;h2 id="choosing-the-order-on-purpose"&gt;Choosing the order on purpose
&lt;/h2&gt;&lt;p&gt;Since a table gets one clustered index, the decision worth making is which order its rows are in, and the answer comes from how the application reads them. Assets belong to clients, and the application mostly reads them client by client: show me this client&amp;rsquo;s laptops, total this client&amp;rsquo;s asset value. So the schema is two tables. &lt;code&gt;clients&lt;/code&gt; keeps its own simple key, and &lt;code&gt;assets&lt;/code&gt; puts &lt;code&gt;client_id&lt;/code&gt; first in its primary key:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;clients&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;assets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;asset_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;asset_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;serial&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;acquired_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;asset_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;assets_asset_id_uq&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The columns are the same as before, and only the last two lines changed. The primary key is now &lt;code&gt;(client_id, asset_id)&lt;/code&gt;, so the clustered index sorts rows by client first, and &lt;code&gt;asset_id&lt;/code&gt; puts each client&amp;rsquo;s assets in the order they arrived. Every asset of client 621 ends up next to the others, on one or two pages, whenever it was added.&lt;/p&gt;
&lt;p&gt;In a real schema &lt;code&gt;assets.client_id&lt;/code&gt; would also be a foreign key to &lt;code&gt;clients&lt;/code&gt;, and the primary key that starts with &lt;code&gt;client_id&lt;/code&gt; already provides the index the foreign key needs. The test tables left the foreign key out, so the insert timings further down do not include that check. &lt;code&gt;asset_id&lt;/code&gt; alone is still unique and still auto-increments, and the &lt;code&gt;UNIQUE KEY&lt;/code&gt; is not optional. Without it, MySQL 8.4 refuses the table:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ERROR 1075 (42000): Incorrect table definition; there can be only one auto column and it must be defined as a key
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;An &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; column has to be the first column of some index, and in the primary key it is second. The same query now reads the clustered index directly:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Index lookup on a_by_client using PRIMARY (client_id=621) (cost=6.07 rows=50) (actual time=0.0123..0.0199 rows=50 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;using PRIMARY&lt;/code&gt; means the search went straight into the clustered index, found the first row for client 621, and read forward through the next 49, which were on the same page or the one after it. That was 1.7 pages from disk on average, against 45.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Bar chart: one client’s assets took 15.0 ms with 45 pages from disk in MySQL clustered on asset_id and 14.8 ms with 46 pages in the PostgreSQL heap, against 1.02 ms after CLUSTER, 0.60 ms with a covering index and 0.53 ms in MySQL clustered on client." loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://explainanalyze.com/p/clustered-vs-non-clustered-index-the-table-is-the-index-in-mysql/one-client-lookup.svg"&gt;&lt;/p&gt;
&lt;p&gt;On a disk that allows 3,000 reads a second, every page read costs a third of a millisecond, so the page count turns straight into time: 15.0 ms on average for the default table, 0.53 ms for the one clustered by client.&lt;/p&gt;
&lt;p&gt;A composite primary key can still look like overthinking it. The default table already had an index on &lt;code&gt;client_id&lt;/code&gt;, the query used it, and with the whole table in memory the gap almost disappears: 0.66 ms against 0.25 ms in a warm run. That is why this kind of change looks pointless on a laptop (I have dismissed it that way myself). The index on &lt;code&gt;client_id&lt;/code&gt; finds the right rows in both tables. What it cannot do is put them on the same page; only the clustered index decides that. The difference shows once the data no longer fits in memory and each of those 45 pages is a trip to disk.&lt;/p&gt;
&lt;p&gt;The composite key also changes every other index on the table, because each one carries a copy of the primary key and ends each lookup with a search of the clustered index. Look up one asset by its id on the client-clustered table:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;asset_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;assets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;asset_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4995192&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;EXPLAIN&lt;/code&gt; shows &lt;code&gt;key: assets_asset_id_uq&lt;/code&gt;, the unique index, with &lt;code&gt;type: const&lt;/code&gt;, one row. What it does not show is that the unique index only holds &lt;code&gt;asset_id&lt;/code&gt; and the primary key &lt;code&gt;(client_id, asset_id)&lt;/code&gt;. InnoDB finds the entry, reads &lt;code&gt;client_id&lt;/code&gt; out of it, and then searches the clustered index for &lt;code&gt;(621, 4995192)&lt;/code&gt;, two trees instead of one. A lookup by id is fast either way, and the extra pages only matter for an application that does millions of them.&lt;/p&gt;
&lt;p&gt;Size is the other cost. I added an index on &lt;code&gt;acquired_at&lt;/code&gt; to both MySQL tables, which at that point held the same 6.5 million rows. With &lt;code&gt;asset_id&lt;/code&gt; as the primary key, each entry holds a date and an 8-byte id. With &lt;code&gt;(client_id, asset_id)&lt;/code&gt;, each entry also carries the 4-byte &lt;code&gt;client_id&lt;/code&gt;, and the index came out 22 percent bigger for the same dates. The manual&amp;rsquo;s advice follows from that: &amp;ldquo;If the primary key is long, the secondary indexes use more space, so it is advantageous to have a short primary key.&amp;rdquo; A 16-byte UUID primary key is copied into every index the same way, which is part of &lt;a class="link" href="https://explainanalyze.com/p/random-uuids-as-primary-keys-the-b-tree-penalty/" &gt;what random UUIDs cost&lt;/a&gt;.&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;What every other index pays&lt;/th&gt;
 &lt;th style="text-align: right"&gt;MySQL, key &lt;code&gt;asset_id&lt;/code&gt;&lt;/th&gt;
 &lt;th style="text-align: right"&gt;MySQL, key &lt;code&gt;(client_id, asset_id)&lt;/code&gt;&lt;/th&gt;
 &lt;th style="text-align: right"&gt;PostgreSQL heap&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;One asset by &lt;code&gt;asset_id&lt;/code&gt;, pages touched per lookup&lt;/td&gt;
 &lt;td style="text-align: right"&gt;4.0&lt;/td&gt;
 &lt;td style="text-align: right"&gt;8.4&lt;/td&gt;
 &lt;td style="text-align: right"&gt;4.0&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Index on &lt;code&gt;acquired_at&lt;/code&gt;&lt;/td&gt;
 &lt;td style="text-align: right"&gt;133 MB&lt;/td&gt;
 &lt;td style="text-align: right"&gt;162 MB&lt;/td&gt;
 &lt;td style="text-align: right"&gt;139 MB, whatever the key&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Index on &lt;code&gt;client_id&lt;/code&gt;&lt;/td&gt;
 &lt;td style="text-align: right"&gt;149 MB&lt;/td&gt;
 &lt;td style="text-align: right"&gt;not needed, the key starts with it&lt;/td&gt;
 &lt;td style="text-align: right"&gt;34 MB&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;PostgreSQL&amp;rsquo;s indexes store a 6-byte row address no matter what the primary key is, which is why its &lt;code&gt;acquired_at&lt;/code&gt; index does not change with the key. Its index on &lt;code&gt;client_id&lt;/code&gt; is under a quarter the size of MySQL&amp;rsquo;s, because PostgreSQL (since version 13) stores a repeated value once with a list of row addresses, while InnoDB stores a full &lt;code&gt;(client_id, asset_id)&lt;/code&gt; entry for each of the 5 million rows.&lt;/p&gt;
&lt;h2 id="the-price-every-new-row-goes-into-the-middle"&gt;The price: every new row goes into the middle
&lt;/h2&gt;&lt;p&gt;What the composite key saves on reads, the table pays back on every insert. An auto-increment primary key puts every new row at the end of the table, on the last page, which is the cheapest place to write. Clustering by client means a new asset for client 621 has to go into client 621&amp;rsquo;s page, somewhere in the middle. If that page is full, InnoDB splits it into two half-full pages to make room, which is a page split. I inserted 1.5 million new assets for random clients into both tables, in batches of 10,000. Random clients is the worst case: if most new assets belong to recently created clients, and &lt;code&gt;client_id&lt;/code&gt; is itself an auto-increment, the inserts land near the end of the index anyway, which I reasoned through but did not measure.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Two bar charts: inserting 500,000 assets took 14.5 s clustered by asset_id and 65.6 s clustered by client; the next 1,000,000 took 34.5 s and 105.7 s. After the inserts the clustered index was 1,167 MB by asset_id and 2,601 MB by client, back to 1,337 MB after OPTIMIZE TABLE." loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://explainanalyze.com/p/clustered-vs-non-clustered-index-the-table-is-the-index-in-mysql/insert-cost.svg"&gt;&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Clustered on &lt;code&gt;asset_id&lt;/code&gt;&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Clustered on &lt;code&gt;(client_id, asset_id)&lt;/code&gt;&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;First 500,000 inserts&lt;/td&gt;
 &lt;td style="text-align: right"&gt;14.5 s, 5,896 page splits&lt;/td&gt;
 &lt;td style="text-align: right"&gt;65.6 s, 65,232 page splits&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Next 1,000,000 inserts&lt;/td&gt;
 &lt;td style="text-align: right"&gt;34.5 s, 12,393 page splits&lt;/td&gt;
 &lt;td style="text-align: right"&gt;105.7 s, 24,424 page splits&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Clustered index after both&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,167 MB&lt;/td&gt;
 &lt;td style="text-align: right"&gt;2,601 MB&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;After &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt;&lt;/td&gt;
 &lt;td style="text-align: right"&gt;&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,337 MB, in 39 s&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The first batch was the worst, because the table had just been loaded in sorted order and every page was nearly full, so almost every new row split a page. Once the pages were half empty there was room, and the second batch split far fewer, but it was still three times slower than appending at the end. The table also more than doubled in size, since all those split pages were half empty. &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt; rebuilds the table packed again. For InnoDB it runs as online DDL, and the &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.4/en/optimize-table.html" target="_blank" rel="noopener"
 &gt;manual&lt;/a&gt; says &amp;ldquo;an exclusive table lock is only taken briefly during the prepare phase and the commit phase&amp;rdquo;, so reads and writes keep going while it works.&lt;/p&gt;
&lt;p&gt;A rebuild can also leave room for the rows that will arrive later. &lt;code&gt;innodb_fill_factor&lt;/code&gt; sets how full InnoDB packs each page when it builds or rebuilds an index in sorted order, and the &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html#sysvar_innodb_fill_factor" target="_blank" rel="noopener"
 &gt;manual&lt;/a&gt; notes that even the default of 100 &amp;ldquo;leaves 1/16 of the space in clustered index pages free for future index growth.&amp;rdquo; I rebuilt the client-clustered table at three settings and inserted the same 500,000 assets into each:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;GLOBAL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;innodb_fill_factor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;assets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FORCE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- OPTIMIZE TABLE rebuilds the same way
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;GLOBAL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;innodb_fill_factor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Client-clustered table&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Size before&lt;/th&gt;
 &lt;th style="text-align: right"&gt;500,000 inserts&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Page splits&lt;/th&gt;
 &lt;th style="text-align: right"&gt;Size after&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;Loaded in sorted order, as above&lt;/td&gt;
 &lt;td style="text-align: right"&gt;900 MB&lt;/td&gt;
 &lt;td style="text-align: right"&gt;58.0 s&lt;/td&gt;
 &lt;td style="text-align: right"&gt;65,232&lt;/td&gt;
 &lt;td style="text-align: right"&gt;2,184 MB&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Rebuilt, &lt;code&gt;innodb_fill_factor = 100&lt;/code&gt;&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,028 MB&lt;/td&gt;
 &lt;td style="text-align: right"&gt;42.8 s&lt;/td&gt;
 &lt;td style="text-align: right"&gt;65,057&lt;/td&gt;
 &lt;td style="text-align: right"&gt;2,181 MB&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Rebuilt, &lt;code&gt;innodb_fill_factor = 80&lt;/code&gt;&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,210 MB&lt;/td&gt;
 &lt;td style="text-align: right"&gt;33.9 s&lt;/td&gt;
 &lt;td style="text-align: right"&gt;3,752&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,267 MB&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Rebuilt, &lt;code&gt;innodb_fill_factor = 70&lt;/code&gt;&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,378 MB&lt;/td&gt;
 &lt;td style="text-align: right"&gt;31.8 s&lt;/td&gt;
 &lt;td style="text-align: right"&gt;547&lt;/td&gt;
 &lt;td style="text-align: right"&gt;1,378 MB&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;At 70 the new assets fit into the room the rebuild left, and the table stayed the size it started. The client lookup barely noticed the emptier pages: 1.6 to 1.9 pages read from disk, against 1.6 when packed. The space is paid up front instead of after the splits, and it only lasts as long as the room does. These inserts added 10 percent more rows into 30 percent of free space, so a table that keeps growing needs the rebuild repeated before the room runs out. &lt;code&gt;innodb_fill_factor&lt;/code&gt; is a global setting that applies to every sorted index build on the server, which is why the example sets it back afterwards. (These runs came from a fresh container, so the packed table&amp;rsquo;s 58.0 s is a little faster than the 65.6 s above. The page splits and sizes matched exactly.)&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Cluster on what you read together, not on what is random&lt;/strong&gt;
 &lt;div&gt;Grouping by client works because a client&amp;rsquo;s assets are read together. A clustered key with no useful order, such as a random UUID, gets all of the insert cost here and none of the read benefit: every new row lands on a random page and splits it, and nothing is ever read in that order.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="postgresql-sort-it-once-or-copy-the-columns-into-an-index"&gt;PostgreSQL: sort it once, or copy the columns into an index
&lt;/h2&gt;&lt;p&gt;PostgreSQL has no clustered index and no way to declare one. Its table is a heap, where a new row goes wherever there is free space, and every index, the primary key included, stores the row&amp;rsquo;s &lt;code&gt;ctid&lt;/code&gt;: a 6-byte address made of the page number and the position on that page. That is the address the &lt;code&gt;client_id&lt;/code&gt; index handed back 50 times in the plan above. PostgreSQL offers two ways to get close to the client-clustered layout, and neither one is a clustered index.&lt;/p&gt;
&lt;p&gt;The first is the &lt;code&gt;CLUSTER&lt;/code&gt; command, which rewrites the table sorted by an index:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CLUSTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;assets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;USING&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;assets_client_id_idx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;It took 23.5 seconds on 5 million rows, and afterwards one client&amp;rsquo;s assets came back in 3.1 pages from disk and 1.02 ms under the disk limit, close to MySQL&amp;rsquo;s client-clustered table. The &lt;a class="link" href="https://www.postgresql.org/docs/18/sql-cluster.html" target="_blank" rel="noopener"
 &gt;PostgreSQL documentation&lt;/a&gt; is clear about what it is: &amp;ldquo;Clustering is a one-time operation: when the table is subsequently updated, the changes are not clustered.&amp;rdquo; New rows still go wherever the heap has room, usually the end. While it runs, &lt;code&gt;CLUSTER&lt;/code&gt; holds an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock, which &amp;ldquo;prevents any other database operations (both reads and writes) from operating on the table until the CLUSTER is finished.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;&lt;img alt="Line chart: pages read per client lookup after CLUSTER rose from 3.1 right after, to 4.7 after 10% more rows, to 14.8 after 30% more; MySQL clustered on client stayed at 1.6, 2.2 and 2.4." loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://explainanalyze.com/p/clustered-vs-non-clustered-index-the-table-is-the-index-in-mysql/cluster-decay.svg"&gt;&lt;/p&gt;
&lt;p&gt;After I added 10 percent more assets, the clustered PostgreSQL table read 4.7 pages per client, and after 30 percent, 14.8 pages and 5.22 ms under the disk limit. The same inserts left MySQL&amp;rsquo;s client-clustered table at 2.4 pages, since InnoDB put each new asset on its client&amp;rsquo;s page. That is the trade from the previous section showing up from the other side: InnoDB pays on every insert to keep the order, and PostgreSQL pays nothing on insert and loses the order.&lt;/p&gt;
&lt;p&gt;The second option is a covering index, an index that carries the columns the query needs so that the table is never read:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;assets_client_cov&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;assets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;INCLUDE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;asset_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Index Only Scan using assets_orig_cov on assets_orig (cost=0.56..5.45 rows=51 width=47) (actual time=0.049..0.061 rows=50.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Index Cond: (client_id = 621)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Heap Fetches: 0
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=4 read=4
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;Index Only Scan&lt;/code&gt; with &lt;code&gt;Heap Fetches: 0&lt;/code&gt; means the answer came entirely from the index, whose entries are sorted by &lt;code&gt;client_id&lt;/code&gt;, so a client&amp;rsquo;s 50 entries sit together. It read 1.7 pages from disk and took 0.60 ms, the same as MySQL&amp;rsquo;s clustered table, and since an index is kept in order on every insert, it does not wear off the way &lt;code&gt;CLUSTER&lt;/code&gt; does. It cost a 398 MB index in place of the 34 MB plain one, and it only helps queries that ask for the included columns. &lt;code&gt;Heap Fetches&lt;/code&gt; also rises when rows change until &lt;code&gt;VACUUM&lt;/code&gt; catches up, which &lt;a class="link" href="https://explainanalyze.com/p/one-column-added-to-a-select-1020-page-reads-became-166667/" &gt;One Column Added to a SELECT&lt;/a&gt; measures.&lt;/p&gt;
&lt;h2 id="how-to-choose-the-clustered-key"&gt;How to choose the clustered key
&lt;/h2&gt;&lt;p&gt;In MySQL, the primary key is the most important index decision on a table, because it is also the table&amp;rsquo;s physical order and part of every other index. An auto-increment id is the right default when rows are mostly written once and read one at a time, or in the order they arrived. A composite key that leads with the owner, such as &lt;code&gt;(client_id, asset_id)&lt;/code&gt; or &lt;code&gt;(tenant_id, order_id)&lt;/code&gt;, pays off when the application nearly always reads a parent&amp;rsquo;s children together and the table does not fit in memory, and it costs slower inserts, a bigger table between rebuilds, and slightly bigger secondary indexes. Rebuilding with &lt;code&gt;innodb_fill_factor&lt;/code&gt; below 100 trades some of that size up front for far fewer page splits. Measure the read you care about in pages, not milliseconds on a warm laptop.&lt;/p&gt;
&lt;p&gt;In PostgreSQL there is no such choice to make, and the table stays in arrival order. If one access pattern dominates, a covering index gives the clustered read without the decay, at the price of the index&amp;rsquo;s size. &lt;code&gt;CLUSTER&lt;/code&gt; is the one-off option for a table that is mostly read and can be locked for the rewrite.&lt;/p&gt;</description></item><item><title>How to Read EXPLAIN ANALYZE: MySQL</title><link>https://explainanalyze.com/p/how-to-read-explain-analyze-mysql/</link><pubDate>Wed, 04 Nov 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/how-to-read-explain-analyze-mysql/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post How to Read EXPLAIN ANALYZE: MySQL" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; runs your query and shows each step MySQL took to answer it, how long each step took, and how many rows it expected at each step next to how many it got. The habit this post teaches is to find the one slow step first and read only its numbers before changing anything. The output tells you where the time went, not why, and MySQL leaves out some things PostgreSQL shows, so the fix still takes some thinking.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Plain &lt;code&gt;EXPLAIN&lt;/code&gt; shows what MySQL plans to do with your query without running it, so every number in it is a guess. &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; runs the query for real and prints the same plan with what happened at each step: rows out, time taken, and how many times the step ran. A plan can look sensible and still read the whole table, and only the second command shows that. You can paste the output into an AI and get a reading back, and it will often point at the right line. What it does not know is your system: which table is the busy one, what the query is for, whether a number that looks large is normal here. That judgment is yours, and this post is about having enough of it to check the reading rather than take it.&lt;/p&gt;
&lt;p&gt;The part of MySQL that decides how to run a query is the optimizer, and it decides before reading any data. It lists the ways it could answer (read the whole table or use an index, which table to start a join with), estimates the cost of each, and picks the cheapest. Its estimates come from statistics InnoDB keeps about each table and its indexes, and from quick samples of an index taken while planning. When a column has no index, the optimizer knows very little about it and falls back on rough rules. That is why plain &lt;code&gt;EXPLAIN&lt;/code&gt; answers instantly, and why it can be badly wrong.&lt;/p&gt;
&lt;p&gt;The executor then runs the chosen plan and counts what happens. &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; prints those counts next to the optimizer&amp;rsquo;s guesses, and reading it mostly means comparing the two.&lt;/p&gt;
&lt;p&gt;&lt;img alt="SQL passes through the parser, resolver and optimizer before any row is read; the iterator executor then runs the plan. EXPLAIN prints the plan with estimates, EXPLAIN ANALYZE adds what actually happened" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://explainanalyze.com/p/how-to-read-explain-analyze-mysql/query-pipeline.svg"&gt;&lt;/p&gt;
&lt;p&gt;Every plan below comes from MySQL 8.4.13 in Docker with default settings, on a &lt;code&gt;customers&lt;/code&gt; table of 200,000 rows (50 countries, 10 cities each) and an &lt;code&gt;orders&lt;/code&gt; table of 5,000,000. Each order belongs to a random customer, so one customer&amp;rsquo;s orders are scattered across the table, the way real orders arrive. &lt;a class="link" href="https://explainanalyze.com/p/how-to-read-explain-analyze-postgresql/" &gt;The PostgreSQL version of this post&lt;/a&gt; uses the same rows.&lt;/p&gt;
&lt;h2 id="what-the-output-is-telling-you"&gt;What the output is telling you
&lt;/h2&gt;&lt;p&gt;Start with the simplest slow query there is: one customer&amp;rsquo;s orders, before any index exists on &lt;code&gt;customer_id&lt;/code&gt;.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4242&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;EXPLAIN FORMAT=TREE&lt;/code&gt; prints the plan as a tree:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Filter: (orders.customer_id = 4242) (cost=529407 rows=496280)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on orders (cost=529407 rows=4.96e+6)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Each line starting with &lt;code&gt;-&amp;gt;&lt;/code&gt; is one step. MySQL calls a step an iterator, because each one hands rows, one at a time, to the step above it. The deeper a line is indented, the earlier it runs, so read from the innermost line out. &lt;code&gt;Table scan on orders&lt;/code&gt; means read every row of the table from start to finish. Its rows go up to &lt;code&gt;Filter&lt;/code&gt;, which is the &lt;code&gt;WHERE&lt;/code&gt; condition, checked against each row. &lt;code&gt;rows=&lt;/code&gt; is the estimate: about 4.96 million rows from the scan (&lt;code&gt;4.96e+6&lt;/code&gt; is engineering notation for 4,960,000), and 496,280 expected to match. &lt;code&gt;cost&lt;/code&gt; is the optimizer&amp;rsquo;s price in its own units, not milliseconds. Now with &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Filter: (orders.customer_id = 4242) (cost=529407 rows=496280) (actual time=16.7..652 rows=27 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on orders (cost=529407 rows=4.96e+6) (actual time=0.224..578 rows=5e+6 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The first parenthesis is still the guess. The second is what happened. &lt;code&gt;actual time=0.224..578&lt;/code&gt; on the table scan is milliseconds until it produced its first row and its last row. &lt;code&gt;rows=5e+6&lt;/code&gt; is the rows it produced: all five million. &lt;code&gt;loops=1&lt;/code&gt; is how many times the step ran, which matters later. On the &lt;code&gt;Filter&lt;/code&gt;, &lt;code&gt;rows=27&lt;/code&gt; is how many matched, against a guess of 496,280.&lt;/p&gt;
&lt;p&gt;Two things are wrong here, and they are different problems. The guess was off by a factor of 18,000, which is a lot even for a guess. With no index on &lt;code&gt;customer_id&lt;/code&gt; the optimizer had little to go on, and its guess works out to exactly a tenth of the table. And even a perfect guess would not have helped, because without an index there was no faster way to find 27 rows than reading all five million. That is what the table scan&amp;rsquo;s &lt;code&gt;rows=5e+6&lt;/code&gt; says, and on a slow query it is the first number worth finding.&lt;/p&gt;
&lt;p&gt;An index is that faster way. It is a separate sorted structure, like the index at the back of a book, that maps each &lt;code&gt;customer_id&lt;/code&gt; to where that customer&amp;rsquo;s rows are stored. After &lt;code&gt;CREATE INDEX orders_customer_id_idx ON orders (customer_id)&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Index lookup on orders using orders_customer_id_idx (customer_id=4242) (cost=28.7 rows=27) (actual time=0.044..0.0452 rows=27 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;One step, a lookup instead of a scan. The guess is now exactly right (&lt;code&gt;rows=27&lt;/code&gt;), because the index gives the optimizer real numbers to work from, and the query takes 0.045 ms instead of 652.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;EXPLAIN ANALYZE really runs the query&lt;/strong&gt;
 &lt;div&gt;For a &lt;code&gt;SELECT&lt;/code&gt; it takes as long as the query takes; the &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.4/en/explain.html" target="_blank" rel="noopener"
 &gt;MySQL manual&lt;/a&gt; notes you can stop it with &lt;code&gt;KILL QUERY&lt;/code&gt; or Ctrl-C. It also accepts some &lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; statements, so treat it as running them and wrap it in &lt;code&gt;START TRANSACTION;&lt;/code&gt; and &lt;code&gt;ROLLBACK;&lt;/code&gt;. Run long queries on a copy or a replica, not the server your users are on.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="when-the-database-guessed-wrong"&gt;When the database guessed wrong
&lt;/h2&gt;&lt;p&gt;When the optimizer guesses wrong it can pick the wrong plan, so the estimate is worth checking on every slow step. This query joins the two tables (a join matches rows in one table to rows in another, here customers to their orders) and totals the orders of every customer in one city:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;C07&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;C07-3&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Aggregate: count(0), sum(o.total) (cost=75057 rows=1) (actual time=84.4..84.4 rows=1 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Nested loop inner join (cost=70156 rows=49011) (actual time=0.222..84 rows=9940 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Filter: ((c.city = &amp;#39;C07-3&amp;#39;) and (c.country = &amp;#39;C07&amp;#39;)) (cost=20384 rows=2001) (actual time=0.103..29.8 rows=400 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on c (cost=20384 rows=200070) (actual time=0.0927..22.6 rows=200000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Index lookup on o using orders_customer_id_idx (customer_id=c.id) (cost=22.4 rows=24.5) (actual time=0.127..0.135 rows=24.9 loops=400)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Read it inside out. The deepest line, &lt;code&gt;Table scan on c&lt;/code&gt;, reads all 200,000 customers. &lt;code&gt;Filter&lt;/code&gt; keeps the ones in that city: it expected 2,001 and found 400. That is the thing to look for, estimated rows against actual rows on the same line. Neither column has an index, and the guess again works out to a tenth per condition: a tenth of a tenth of 200,070 is 2,001.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Nested loop inner join&lt;/code&gt; is one way to do a join. It has two children at the same depth: the first (the filtered customers) runs once, and for each row it produces, the second (find that customer&amp;rsquo;s orders through the index) runs once. That is what &lt;code&gt;loops=400&lt;/code&gt; on the &lt;code&gt;Index lookup&lt;/code&gt; means. When &lt;code&gt;loops&lt;/code&gt; is above 1, &lt;code&gt;rows&lt;/code&gt; and &lt;code&gt;actual time&lt;/code&gt; are averages per run, so multiply them back: 24.9 rows times 400 is the 9,940 rows the join produced, and 0.135 ms times 400 is 54 ms, most of the 84. The join&amp;rsquo;s own estimate was 49,011 rows against 9,940, the first mistake carried up the tree.&lt;/p&gt;
&lt;p&gt;Here the wrong guess did no harm, because with 2,001 customers or 400, looking up each one&amp;rsquo;s orders in the index is a good plan. A gap matters when the right number would have made the optimizer choose differently, and the bigger the gap, the more likely that is. The next section has one where a wrong guess fed into a choice of index.&lt;/p&gt;
&lt;h2 id="when-the-plan-is-fine-but-still-slow"&gt;When the plan is fine but still slow
&lt;/h2&gt;&lt;p&gt;The first plan already showed the second lesson: a scan that read the whole table, given away by &lt;code&gt;rows=5e+6&lt;/code&gt; on the &lt;code&gt;Table scan&lt;/code&gt; line. Here it is in a bigger tree to practice on, the 100 largest orders since November 1 with each customer&amp;rsquo;s location:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2024-11-01&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LIMIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Limit: 100 row(s) (cost=2.29e+6 rows=100) (actual time=789..789 rows=100 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Nested loop inner join (cost=2.29e+6 rows=4.96e+6) (actual time=789..789 rows=100 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Sort: o.total DESC (cost=529519 rows=4.96e+6) (actual time=789..789 rows=100 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Filter: (o.created_at &amp;gt;= TIMESTAMP&amp;#39;2024-11-01 00:00:00&amp;#39;) (cost=529519 rows=4.96e+6) (actual time=585..675 rows=608001 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on o (cost=529519 rows=4.96e+6) (actual time=0.114..572 rows=5e+6 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Single-row index lookup on c using PRIMARY (id=o.customer_id) (cost=0.997 rows=1) (actual time=0.0054..0.00541 rows=1 loops=100)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Each step&amp;rsquo;s time includes the time of the steps under it, so to see what one step cost by itself, subtract its children&amp;rsquo;s finishing times from its own. &lt;code&gt;Sort&lt;/code&gt; finished at 789 ms and its child at 675, so sorting cost 114 ms. &lt;code&gt;Filter&lt;/code&gt; finished at 675 and the scan under it at 572, so checking the dates cost 103. &lt;code&gt;Table scan on o&lt;/code&gt; cost 572 ms by itself, more than everything else put together. It read five million rows to keep 608,001. The customer lookups ran 100 times at 0.0054 ms each, half a millisecond in all.&lt;/p&gt;
&lt;p&gt;Two more things are visible once you know where to look. The &lt;code&gt;Sort&lt;/code&gt; produced its first row at the same moment as its last (789..789), because a sort cannot hand anything up until it has seen all its input. And the &lt;code&gt;Filter&lt;/code&gt; produced its first row at 585 ms, because the newest orders sit at the end of the table and the scan reaches them last.&lt;/p&gt;
&lt;p&gt;There was an index on &lt;code&gt;created_at&lt;/code&gt; while this ran, and MySQL did not use it. It estimated about 1.27 million matching rows (the real number was 608,001), and priced 1.27 million trips from the index back to the table as dearer than one pass over the whole table. The guess was off by half; the plan alone cannot say whether the right number would have changed the choice. An index that holds every column the query needs never makes those trips. After &lt;code&gt;CREATE INDEX orders_created_total_cust_idx ON orders (created_at, total, customer_id)&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Limit: 100 row(s) (cost=1.49e+6 rows=100) (actual time=183..183 rows=100 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Nested loop inner join (cost=1.49e+6 rows=1.28e+6) (actual time=183..183 rows=100 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Sort: o.total DESC (cost=258614 rows=1.28e+6) (actual time=183..183 rows=100 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Filter: (o.created_at &amp;gt;= TIMESTAMP&amp;#39;2024-11-01 00:00:00&amp;#39;) (cost=258614 rows=1.28e+6) (actual time=0.0673..73.8 rows=608001 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Covering index range scan on o using orders_created_total_cust_idx over (&amp;#39;2024-11-01 00:00:00&amp;#39; &amp;lt;= created_at) (cost=258614 rows=1.28e+6) (actual time=0.0669..54.9 rows=608001 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Single-row index lookup on c using PRIMARY (id=o.customer_id) (cost=0.862 rows=1) (actual time=0.00143..0.00145 rows=1 loops=100)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;Covering index range scan&lt;/code&gt; means MySQL read only the part of the index from November 1 onward, and found everything it needed there. 183 ms instead of 789. The sort is now the slowest step at 109 ms, and that part is the price of the question: finding the 100 largest of 608,001 orders means looking at all 608,001. The new index costs something too, 152 MB on disk and extra work on every insert, so it is worth it for a query that runs often and maybe not for a nightly report.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Next thing to learn: counting what MySQL read&lt;/strong&gt;
 &lt;div&gt;PostgreSQL&amp;rsquo;s plan prints how many pages each step read. MySQL&amp;rsquo;s does not, and it does not say when a sort spilled to disk either. Session status counters fill part of the gap: run &lt;code&gt;FLUSH STATUS&lt;/code&gt;, then the query, then &lt;code&gt;SHOW SESSION STATUS LIKE 'Handler_read%'&lt;/code&gt;. For the one-customer query without an index, &lt;code&gt;Handler_read_rnd_next&lt;/code&gt; was 5,000,001, one per row scanned. With the index, it was 0, and &lt;code&gt;Handler_read_next&lt;/code&gt; was 27.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="what-the-warning-signs-mean"&gt;What the warning signs mean
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;Table scan&lt;/code&gt; under a &lt;code&gt;Filter&lt;/code&gt; that keeps a small share of its rows means MySQL read the whole table to find a few. The usual fix is an index on the &lt;code&gt;WHERE&lt;/code&gt; column. If one exists and goes unused, check whether the condition wraps the column in a function, which hides it from the index; &lt;a class="link" href="https://explainanalyze.com/p/non-sargable-predicates-how-a-function-in-where-kills-your-index/" &gt;Non-SARGable Predicates&lt;/a&gt; explains that one.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Index range scan&lt;/code&gt; or &lt;code&gt;Index lookup&lt;/code&gt; where you might have had &lt;code&gt;Covering index range scan&lt;/code&gt; means MySQL found the rows in the index, then went back to the table for each one to fetch the other columns. Counting 25,182 orders for a range of customers took 2.3 ms as a covering scan. Summing their &lt;code&gt;total&lt;/code&gt;, a column not in the index, took 95 ms. Adding the missing column to the index is the usual fix, with the size cost above.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Nested loop inner join&lt;/code&gt; with a large &lt;code&gt;loops&lt;/code&gt; on its second child means one lookup repeated many times. Fine when the repeated step is an index lookup, as in both joins above. When it is a &lt;code&gt;Table scan&lt;/code&gt;, the join column has no index and the whole table is read once per loop.&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;Sort&lt;/code&gt; with a large input means every row has to be read before the first one comes out. If the sort is the slow step, ask whether the query needs all those rows. The tree will not tell you whether it spilled to disk.&lt;/p&gt;
&lt;h2 id="the-first-five-things-to-look-at"&gt;The first five things to look at
&lt;/h2&gt;&lt;ol&gt;
&lt;li&gt;The top line&amp;rsquo;s last time: the number you want smaller.&lt;/li&gt;
&lt;li&gt;The slowest step. From the top, subtract each step&amp;rsquo;s children from its own finishing time until you find the step that is slow by itself.&lt;/li&gt;
&lt;li&gt;On that step, actual &lt;code&gt;rows&lt;/code&gt; against the estimate. A big difference means the optimizer guessed wrong.&lt;/li&gt;
&lt;li&gt;Under that step, a &lt;code&gt;Table scan&lt;/code&gt; whose &lt;code&gt;rows&lt;/code&gt; is close to the table&amp;rsquo;s size, when only a few rows survive. That means a missing index.&lt;/li&gt;
&lt;li&gt;Rows handled against rows needed. Thousands sorted for a page of 100 means the query asks for more than the question.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;When you are ready for a real one, &lt;a class="link" href="https://explainanalyze.com/p/a-query-on-a-3-billion-row-table-took-100-minutes-one-added-line-made-it-40ms/" &gt;A Query on a 3-Billion-Row Table Took 100 Minutes&lt;/a&gt; reads a production MySQL plan end to end the same way.&lt;/p&gt;
&lt;h2 id="the-ways-mysql-reads-a-table"&gt;The ways MySQL reads a table
&lt;/h2&gt;&lt;p&gt;Every leaf of the tree is one of a short list of access methods, and the name says how much of the table or index MySQL expects to touch. The older &lt;code&gt;EXPLAIN&lt;/code&gt; output, the one without &lt;code&gt;FORMAT=TREE&lt;/code&gt; that most tutorials still show, names the same methods in its &lt;code&gt;type&lt;/code&gt; column, so both are listed. Rows are ordered from cheapest to most expensive. The names below were taken from MySQL 8.4.13 on a small separate schema, since the post&amp;rsquo;s own fixture does not produce all of them.&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;What the tree says&lt;/th&gt;
 &lt;th&gt;&lt;code&gt;type&lt;/code&gt; in classic EXPLAIN&lt;/th&gt;
 &lt;th&gt;What MySQL does&lt;/th&gt;
 &lt;th&gt;What it usually means&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Rows fetched before execution&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;const&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Looked up one row by primary key or unique index while planning; the row is a constant by the time the query runs&lt;/td&gt;
 &lt;td&gt;As good as it gets&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Single-row index lookup on t using PRIMARY (id=...)&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;eq_ref&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;For each row from the previous step, one lookup that returns at most one row&lt;/td&gt;
 &lt;td&gt;The normal shape of a join on a primary key&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Index lookup on t using k (col=...)&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;ref&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Finds every row with one value through a non-unique index, then fetches each row from the table&lt;/td&gt;
 &lt;td&gt;Fine when &lt;code&gt;rows&lt;/code&gt; is small; expensive when one value matches thousands&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Covering index lookup on t using k (col=...)&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;ref&lt;/code&gt; + &lt;code&gt;Using index&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Same lookup, but every column the query needs is in the index, so the table is never read&lt;/td&gt;
 &lt;td&gt;Cheaper than a plain lookup; adding a column to the SELECT can turn it back into one&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Index lookup ... (col=... or NULL)&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;ref_or_null&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;A lookup that also fetches the rows where the column is NULL&lt;/td&gt;
 &lt;td&gt;A &lt;code&gt;col = x OR col IS NULL&lt;/code&gt; condition&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Index range scan on t using k over (a &amp;lt; col &amp;lt; b)&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;range&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Walks the part of the index between two bounds, fetching rows from the table&lt;/td&gt;
 &lt;td&gt;Normal for date ranges; watch &lt;code&gt;rows&lt;/code&gt; against what the query returns&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Covering index range scan on t using k over (...)&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;range&lt;/code&gt; + &lt;code&gt;Using index&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;The same walk without touching the table&lt;/td&gt;
 &lt;td&gt;The cheapest range&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Sort-deduplicate by row ID&lt;/code&gt; over two range scans&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;index_merge&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Runs two index scans and combines their row IDs&lt;/td&gt;
 &lt;td&gt;An &lt;code&gt;OR&lt;/code&gt; across two indexed columns; often slower than it looks&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Index scan on t using k&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;index&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Reads the whole index in order and fetches every row from the table&lt;/td&gt;
 &lt;td&gt;Usually an &lt;code&gt;ORDER BY&lt;/code&gt; on an indexed column with no filter; expensive when the table is large&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Covering index scan on t using k&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;index&lt;/code&gt; + &lt;code&gt;Using index&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Reads the whole index without the table&lt;/td&gt;
 &lt;td&gt;Cheaper than a table scan because the index is smaller, still every row&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Table scan on t&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;ALL&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Reads every row in the table&lt;/td&gt;
 &lt;td&gt;The one to look for when a query is slow and only a few rows survive the &lt;code&gt;Filter&lt;/code&gt; above it&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Zero rows (Impossible WHERE)&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;Impossible WHERE&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;The optimizer proved no row can match and reads nothing&lt;/td&gt;
 &lt;td&gt;A condition that contradicts itself&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A few more names appear above the leaves and describe what happens to rows once they are read. &lt;code&gt;Filter:&lt;/code&gt; keeps the rows that pass a condition the index could not check. &lt;code&gt;Nested loop inner join&lt;/code&gt; runs its second child once per row from the first, which is where &lt;code&gt;loops&lt;/code&gt; comes from. &lt;code&gt;Inner hash join&lt;/code&gt; reads the smaller side once into memory and probes it, and the classic output shows it as &lt;code&gt;Using join buffer (hash join)&lt;/code&gt;. &lt;code&gt;Sort:&lt;/code&gt; orders rows in memory or in a temporary file, which the plan does not distinguish. &lt;code&gt;Limit:&lt;/code&gt;, &lt;code&gt;Group aggregate:&lt;/code&gt; and &lt;code&gt;Aggregate:&lt;/code&gt; do what their names say, and &lt;code&gt;Materialize&lt;/code&gt; writes an intermediate result to a temporary table that a &lt;code&gt;Table scan&lt;/code&gt; above it then reads.&lt;/p&gt;
&lt;p&gt;The test data was generated once with PostgreSQL (the script is at the end of &lt;a class="link" href="https://explainanalyze.com/p/how-to-read-explain-analyze-postgresql/" &gt;the PostgreSQL post&lt;/a&gt;), exported to CSV, and loaded into these tables on MySQL 8.4:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LOAD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;INFILE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;/var/lib/mysql-files/customers.csv&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;FIELDS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TERMINATED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;,&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LOAD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DATA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;INFILE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;/var/lib/mysql-files/orders.csv&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;FIELDS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TERMINATED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;,&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;</description></item><item><title>How to Read EXPLAIN ANALYZE: PostgreSQL</title><link>https://explainanalyze.com/p/how-to-read-explain-analyze-postgresql/</link><pubDate>Sun, 01 Nov 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/how-to-read-explain-analyze-postgresql/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post How to Read EXPLAIN ANALYZE: PostgreSQL" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; runs your query and shows each step PostgreSQL took to answer it, how long each step took, and how many rows it expected at each step next to how many it got. The habit this post teaches is to find the one slow step first and read only its numbers before changing anything. The output tells you where the time went, not why, so the fix still takes some thinking.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Plain &lt;code&gt;EXPLAIN&lt;/code&gt; shows what PostgreSQL plans to do with your query without running it, so every number in it is a guess. &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; runs the query for real and prints the same plan with what happened at each step: rows out, time taken, and how much of the table was read. A plan can look sensible and still read the whole table, and only the second command shows that. You can paste the output into an AI and get a reading back, and it will often point at the right line. What it does not know is your system: which table is the busy one, what the query is for, whether a number that looks large is normal here. That judgment is yours, and this post is about having enough of it to check the reading rather than take it.&lt;/p&gt;
&lt;p&gt;The part of PostgreSQL that decides how to run a query is the planner, and it decides before reading any data. It lists the ways it could answer (read the whole table or use an index, which table to start a join with), estimates the cost of each, and picks the cheapest. Its estimates come from statistics that the &lt;code&gt;ANALYZE&lt;/code&gt; command collects, such as how many rows a table has and which values are common. It never looks at the data itself, which is why plain &lt;code&gt;EXPLAIN&lt;/code&gt; answers instantly and why it can be wrong.&lt;/p&gt;
&lt;p&gt;The executor then runs the chosen plan and counts what happens. &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; prints those counts next to the planner&amp;rsquo;s guesses, and reading it mostly means comparing the two.&lt;/p&gt;
&lt;p&gt;&lt;img alt="SQL passes through the parser, rewriter and planner before any row is read; the executor then runs the plan. EXPLAIN prints the plan with estimates, EXPLAIN ANALYZE adds what actually happened" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://explainanalyze.com/p/how-to-read-explain-analyze-postgresql/query-pipeline.svg"&gt;&lt;/p&gt;
&lt;p&gt;Every plan below comes from PostgreSQL 18.6 in Docker with default settings, on a &lt;code&gt;customers&lt;/code&gt; table of 200,000 rows (50 countries, 10 cities each) and an &lt;code&gt;orders&lt;/code&gt; table of 5,000,000. Each order belongs to a random customer, so one customer&amp;rsquo;s orders are scattered across the table, the way real orders arrive. I turned off JIT compilation and parallel workers to keep the output short. &lt;a class="link" href="https://explainanalyze.com/p/how-to-read-explain-analyze-mysql/" &gt;The MySQL version of this post&lt;/a&gt; uses the same rows.&lt;/p&gt;
&lt;h2 id="what-the-output-is-telling-you"&gt;What the output is telling you
&lt;/h2&gt;&lt;p&gt;Start with the simplest slow query there is: one customer&amp;rsquo;s orders.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4242&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Plain &lt;code&gt;EXPLAIN&lt;/code&gt; prints this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Seq Scan on orders (cost=0.00..145287.00 rows=26 width=22)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Filter: (customer_id = 4242)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;A plan with one step. &lt;code&gt;Seq Scan&lt;/code&gt; (sequential scan, or table scan) means read every row of the table from start to finish. &lt;code&gt;Filter&lt;/code&gt; is the &lt;code&gt;WHERE&lt;/code&gt; condition, checked against each row. &lt;code&gt;rows=26&lt;/code&gt; is the estimate: the planner expects 26 matches. &lt;code&gt;cost&lt;/code&gt; is the planner&amp;rsquo;s price in its own units, not milliseconds. Now with &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Seq Scan on orders (cost=0.00..145287.00 rows=26 width=22) (actual time=3.082..144.209 rows=27.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Filter: (customer_id = 4242)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Rows Removed by Filter: 4999973
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=8694 read=74093
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Planning Time: 0.091 ms
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Execution Time: 144.249 ms
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The first parenthesis is still the guess. The second is what happened. &lt;code&gt;actual time=3.082..144.209&lt;/code&gt; is milliseconds until the step produced its first row and its last row. &lt;code&gt;rows=27.00&lt;/code&gt; is the rows it produced, so the guess of 26 was close. &lt;code&gt;loops=1&lt;/code&gt; is how many times the step ran, which matters later.&lt;/p&gt;
&lt;p&gt;The next two lines are the price. &lt;code&gt;Rows Removed by Filter: 4999973&lt;/code&gt; says PostgreSQL looked at all 5,000,000 rows and threw away all but 27. &lt;code&gt;Buffers&lt;/code&gt; counts pages: PostgreSQL stores a table in 8 KB pages of dozens of rows each, and always reads whole pages. &lt;code&gt;shared hit&lt;/code&gt; is pages already in its memory cache (shared buffers), and &lt;code&gt;read&lt;/code&gt; is pages it asked the operating system for. Together they are 82,787 pages, the whole table. &lt;code&gt;Execution Time&lt;/code&gt; is the total, 144 ms.&lt;/p&gt;
&lt;p&gt;The estimate was right and the query was still slow, because without an index there was no faster way to find 27 rows than reading everything. An index is a separate sorted structure, like the index at the back of a book, that maps each &lt;code&gt;customer_id&lt;/code&gt; to the pages holding that customer&amp;rsquo;s rows. After &lt;code&gt;CREATE INDEX ON orders (customer_id)&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Bitmap Heap Scan on orders (cost=4.63..107.58 rows=26 width=22) (actual time=0.006..0.014 rows=27.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Recheck Cond: (customer_id = 4242)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Heap Blocks: exact=27
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=30
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Bitmap Index Scan on orders_customer_id_idx (cost=0.00..4.63 rows=26 width=0) (actual time=0.003..0.003 rows=27.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Index Cond: (customer_id = 4242)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Index Searches: 1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=3
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Execution Time: 0.019 ms
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Now the plan has two steps, called nodes. A line starting with &lt;code&gt;-&amp;gt;&lt;/code&gt; is a node that feeds rows to the node above it, and the deeper it is indented, the earlier it runs. Read innermost first.&lt;/p&gt;
&lt;p&gt;The innermost node, &lt;code&gt;Bitmap Index Scan&lt;/code&gt;, looked up 4242 in the index (&lt;code&gt;Index Cond&lt;/code&gt;), found 27 entries, and read 3 index pages. It hands the row locations up to &lt;code&gt;Bitmap Heap Scan&lt;/code&gt;, which fetches those rows from the table (PostgreSQL calls a table&amp;rsquo;s storage the heap). &lt;code&gt;Heap Blocks: exact=27&lt;/code&gt; says the 27 rows sat on 27 different pages. A parent&amp;rsquo;s time and buffers include its children&amp;rsquo;s, so the top &lt;code&gt;Buffers: shared hit=30&lt;/code&gt; is 3 index pages plus 27 table pages. Thirty pages instead of 82,787, and 0.019 ms instead of 144.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;EXPLAIN ANALYZE really runs the query&lt;/strong&gt;
 &lt;div&gt;For a &lt;code&gt;SELECT&lt;/code&gt; it takes as long as the query takes. For &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt; or &lt;code&gt;INSERT&lt;/code&gt; the change really happens, so wrap it in &lt;code&gt;BEGIN;&lt;/code&gt; and &lt;code&gt;ROLLBACK;&lt;/code&gt;, as the &lt;a class="link" href="https://www.postgresql.org/docs/18/sql-explain.html" target="_blank" rel="noopener"
 &gt;PostgreSQL documentation&lt;/a&gt; suggests. Run long queries on a copy or a replica, not the server your users are on.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="when-the-database-guessed-wrong"&gt;When the database guessed wrong
&lt;/h2&gt;&lt;p&gt;When the planner guesses wrong it can pick the wrong plan, so the estimate is worth checking on every slow step. This query joins the two tables (a join matches rows in one table to rows in another, here customers to their orders) and totals the orders of every customer in one city:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;C07&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;C07-3&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Aggregate (cost=5137.70..5137.71 rows=1 width=40) (actual time=91.548..91.554 rows=1.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=3861 read=8553 written=5
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Nested Loop (cost=4.63..5136.69 rows=200 width=6) (actual time=0.110..90.336 rows=9940.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=3861 read=8553 written=5
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Seq Scan on customers c (cost=0.00..4274.00 rows=8 width=4) (actual time=0.010..6.803 rows=400.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Filter: ((country = &amp;#39;C07&amp;#39;::text) AND (city = &amp;#39;C07-3&amp;#39;::text))
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Rows Removed by Filter: 199600
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=1274
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Bitmap Heap Scan on orders o (cost=4.63..107.58 rows=26 width=10) (actual time=0.044..0.205 rows=24.85 loops=400)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Recheck Cond: (c.id = customer_id)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Heap Blocks: exact=9940
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=2587 read=8553 written=5
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Bitmap Index Scan on orders_customer_id_idx (cost=0.00..4.63 rows=26 width=0) (actual time=0.014..0.014 rows=24.85 loops=400)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Index Cond: (customer_id = c.id)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Index Searches: 400
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=785 read=415
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Execution Time: 91.591 ms
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Read it inside out. &lt;code&gt;Seq Scan on customers&lt;/code&gt; reads all 200,000 customers and keeps the ones in that city. It expected 8 and found 400. That is the thing to look for: estimated rows against actual rows on the same line.&lt;/p&gt;
&lt;p&gt;The planner assumes country and city are unrelated, so it multiplies their odds: 200,000 customers, times 1 in 50 for the country, times 1 in 500 for the city, is 8. In this data the city already decides the country, which the planner had no way to know, and the mistake carries up the tree: &lt;code&gt;Nested Loop&lt;/code&gt; expected 200 rows and produced 9,940.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Nested Loop&lt;/code&gt; is one way to do a join: for each row from its first child (a customer), it runs its second child once (find that customer&amp;rsquo;s orders through the index). That is what &lt;code&gt;loops=400&lt;/code&gt; on the orders side means. When &lt;code&gt;loops&lt;/code&gt; is above 1, &lt;code&gt;rows&lt;/code&gt; and &lt;code&gt;actual time&lt;/code&gt; are averages per run, so multiply them back: 24.85 rows times 400 is the 9,940, and 0.205 ms times 400 is 82 ms, most of the 91. Buffers are already totals.&lt;/p&gt;
&lt;p&gt;Here the wrong guess did no harm, because with 8 customers or 400, looking up each one&amp;rsquo;s orders in the index is a good plan. A gap like this matters when the right number would have made the planner choose a different plan, and the bigger the gap, the more likely that is. &lt;a class="link" href="https://explainanalyze.com/p/uniqueness-and-selectivity-the-two-numbers-that-drive-query-plans/" &gt;Uniqueness and Selectivity&lt;/a&gt; shows one that went from 50 ms to 50 minutes.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Next thing to learn: teaching the planner about related columns&lt;/strong&gt;
 &lt;div&gt;&lt;code&gt;CREATE STATISTICS customers_country_city (dependencies) ON country, city FROM customers&lt;/code&gt;, then &lt;code&gt;ANALYZE customers&lt;/code&gt;, tells PostgreSQL the two columns move together. The estimate went from 8 to 398 and the query got no faster, because the plan did not need to change. Fixing an estimate and fixing a query are different jobs.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="when-the-plan-is-fine-but-still-slow"&gt;When the plan is fine but still slow
&lt;/h2&gt;&lt;p&gt;The first plan already showed the second lesson: a correct estimate and a slow query, given away by &lt;code&gt;Rows Removed by Filter&lt;/code&gt; and &lt;code&gt;Buffers&lt;/code&gt;. Here it is in a bigger tree to practice on, a query listing every order since November 1 with its customer&amp;rsquo;s location, largest total first:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Sort (cost=234697.30..236254.94 rows=623058 width=24) (actual time=512.409..573.290 rows=608001.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Sort Key: o.total DESC
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Sort Method: external merge Disk: 20848kB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=14992 read=69072, temp read=4525 written=4535
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Hash Join (cost=6751.00..161952.56 rows=623058 width=24) (actual time=239.832..395.547 rows=608001.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Hash Cond: (o.customer_id = c.id)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=14989 read=69072, temp read=1919 written=1919
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Seq Scan on orders o (cost=0.00..145287.00 rows=623058 width=18) (actual time=219.531..257.878 rows=608001.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Filter: (created_at &amp;gt;= &amp;#39;2024-11-01 00:00:00+00&amp;#39;::timestamp with time zone)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Rows Removed by Filter: 4391999
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=13715 read=69072
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Hash (cost=3274.00..3274.00 rows=200000 width=14) (actual time=20.133..20.134 rows=200000.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buckets: 262144 Batches: 2 Memory Usage: 6749kB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=1274, temp written=438
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Seq Scan on customers c (cost=0.00..3274.00 rows=200000 width=14) (actual time=0.010..6.891 rows=200000.00 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Buffers: shared hit=1274
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Execution Time: 584.068 ms
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The lines that jump out are &lt;code&gt;Sort Method: external merge Disk: 20848kB&lt;/code&gt; and &lt;code&gt;Batches: 2&lt;/code&gt;. Both mean a spill: the step needed more working memory than PostgreSQL gives one step by default (4 MB), so it wrote part of its work to a temporary file and read it back. It is tempting to start there. Find the slow step first.&lt;/p&gt;
&lt;p&gt;Each node&amp;rsquo;s time includes its children&amp;rsquo;s, so to see what one node cost by itself, subtract its children&amp;rsquo;s finishing times from its own. &lt;code&gt;Sort&lt;/code&gt; finished at 573 ms and its child, the &lt;code&gt;Hash Join&lt;/code&gt;, at 396, so the sort cost 178 ms. The &lt;code&gt;Hash Join&lt;/code&gt; (a join that loads one table into memory, the &lt;code&gt;Hash&lt;/code&gt; node, and then matches the other table&amp;rsquo;s rows against it) finished at 396, minus 258 for one child and 20 for the other, which leaves 118 ms. &lt;code&gt;Seq Scan on orders&lt;/code&gt; cost 258 ms on its own, the biggest single share. It read the whole table and threw away 4,391,999 rows to keep 608,001.&lt;/p&gt;
&lt;p&gt;That leaves two things to fix. The filter on &lt;code&gt;created_at&lt;/code&gt; has no index, so I added an index on that column. Over four runs each, the query went from 450 to 570 ms down to 310 to 340 ms, with both spills still there. And the query asks for 608,001 sorted rows. If the screen shows the top 100, ask for 100, in a subquery with &lt;code&gt;ORDER BY total DESC LIMIT 100&lt;/code&gt; that runs before the join, so only 100 orders need a customer looked up. With the index that ran in 76 ms, and its sort used 36 kB of memory instead of a 20 MB file. Giving the original query 16 times more memory removed both spills and saved only 40 to 60 ms. The spills were real, and the smallest problem in the tree.&lt;/p&gt;
&lt;h2 id="what-the-warning-signs-mean"&gt;What the warning signs mean
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;Seq Scan&lt;/code&gt; with a large &lt;code&gt;Rows Removed by Filter&lt;/code&gt; means PostgreSQL read the whole table to find a few rows. The usual fix is an index on the &lt;code&gt;WHERE&lt;/code&gt; column. If one exists and goes unused, check whether the condition wraps the column in a function, which hides it from the index; &lt;a class="link" href="https://explainanalyze.com/p/non-sargable-predicates-how-a-function-in-where-kills-your-index/" &gt;Non-SARGable Predicates&lt;/a&gt; explains that one.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Index Only Scan&lt;/code&gt; with &lt;code&gt;Heap Fetches&lt;/code&gt; above zero means the answer should have come from the index alone, but PostgreSQL still visited the table for some rows to check they are visible to your query. Counting 25,182 orders took 1.3 ms with &lt;code&gt;Heap Fetches: 0&lt;/code&gt;, and 26.7 ms with &lt;code&gt;Heap Fetches: 15709&lt;/code&gt; after an &lt;code&gt;UPDATE&lt;/code&gt; touched rows across the table. &lt;code&gt;VACUUM&lt;/code&gt; brings it back down; &lt;a class="link" href="https://explainanalyze.com/p/one-column-added-to-a-select-1020-page-reads-became-166667/" &gt;One Column Added to a SELECT&lt;/a&gt; goes further.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Nested Loop&lt;/code&gt; with a large &lt;code&gt;loops&lt;/code&gt; on its second child means one lookup repeated many times. Fine when the repeated step uses an index, as above. When it is a &lt;code&gt;Seq Scan&lt;/code&gt;, the join column has no index and the whole table is read once per loop.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Sort&lt;/code&gt; with &lt;code&gt;Disk&lt;/code&gt;, or &lt;code&gt;Hash&lt;/code&gt; with &lt;code&gt;Batches&lt;/code&gt; above 1, means a spill. If the subtraction says it is not where the time goes, leave it. If it is, send fewer rows into the sort or join. Raising the memory setting (&lt;code&gt;work_mem&lt;/code&gt;) server-wide is risky, because every sort in every connection can use that much; &lt;a class="link" href="https://explainanalyze.com/p/its-almost-always-the-queries-part-iv-when-the-sort-spills/" &gt;Part IV of the queries series&lt;/a&gt; explains why.&lt;/p&gt;
&lt;h2 id="the-first-five-things-to-look-at"&gt;The first five things to look at
&lt;/h2&gt;&lt;ol&gt;
&lt;li&gt;&lt;code&gt;Execution Time&lt;/code&gt; at the bottom: the number you want smaller.&lt;/li&gt;
&lt;li&gt;The slowest step. From the top, subtract each step&amp;rsquo;s children from its own finishing time until you find the step that is slow by itself.&lt;/li&gt;
&lt;li&gt;On that step, actual &lt;code&gt;rows&lt;/code&gt; against the estimate. A big difference means the planner guessed wrong.&lt;/li&gt;
&lt;li&gt;On that step, &lt;code&gt;Rows Removed by Filter&lt;/code&gt; and &lt;code&gt;Buffers&lt;/code&gt;. Millions removed, or pages near the table&amp;rsquo;s size, mean a missing index.&lt;/li&gt;
&lt;li&gt;Rows handled against rows needed. Thousands sorted for a page of 100 means the query asks for more than the question.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;When you are ready for a real one, &lt;a class="link" href="https://explainanalyze.com/p/a-query-on-a-3-billion-row-table-took-100-minutes-one-added-line-made-it-40ms/" &gt;A Query on a 3-Billion-Row Table Took 100 Minutes&lt;/a&gt; reads a production plan end to end the same way.&lt;/p&gt;
&lt;h2 id="the-ways-postgresql-reads-a-table"&gt;The ways PostgreSQL reads a table
&lt;/h2&gt;&lt;p&gt;Every leaf of the tree is one of a short list of scan types, and the name says how much of the table or index PostgreSQL expects to touch. Rows are ordered from cheapest to most expensive. The names were taken from PostgreSQL 18.6 on a small separate schema, since the post&amp;rsquo;s own fixture does not produce all of them.&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;What the plan says&lt;/th&gt;
 &lt;th&gt;What PostgreSQL does&lt;/th&gt;
 &lt;th&gt;What it usually means&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Index Only Scan using k on t&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Reads the index and never the table, because every column the query needs is in the index&lt;/td&gt;
 &lt;td&gt;The cheapest way to get rows; &lt;code&gt;Heap Fetches&lt;/code&gt; above zero means the table had to be checked after all, and &lt;code&gt;VACUUM&lt;/code&gt; brings it back to zero&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Index Scan using k on t&lt;/code&gt; with &lt;code&gt;Index Cond&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Walks the index for the matching range, then fetches each row from the table in index order&lt;/td&gt;
 &lt;td&gt;The normal lookup for a primary key or a small range; expensive when the range is wide, because every row is a separate table read&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Bitmap Index Scan&lt;/code&gt; under a &lt;code&gt;Bitmap Heap Scan&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Collects every matching row&amp;rsquo;s location from the index first, then reads the table pages in order, each once&lt;/td&gt;
 &lt;td&gt;What the planner picks when an index matches many scattered rows; &lt;code&gt;Recheck Cond&lt;/code&gt; and &lt;code&gt;Heap Blocks&lt;/code&gt; tell you how much of the table it touched&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;BitmapOr&lt;/code&gt; or &lt;code&gt;BitmapAnd&lt;/code&gt; over two &lt;code&gt;Bitmap Index Scan&lt;/code&gt;s&lt;/td&gt;
 &lt;td&gt;Combines the row locations from two indexes before reading the table&lt;/td&gt;
 &lt;td&gt;An &lt;code&gt;OR&lt;/code&gt; or &lt;code&gt;AND&lt;/code&gt; across two indexed columns&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Seq Scan on t&lt;/code&gt; with &lt;code&gt;Filter&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Reads every row in the table and keeps the ones that pass the filter&lt;/td&gt;
 &lt;td&gt;The one to look for when a query is slow: &lt;code&gt;Rows Removed by Filter&lt;/code&gt; close to the table&amp;rsquo;s size, for a handful of rows kept, means a missing index&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Seq Scan on t&lt;/code&gt; with no &lt;code&gt;Filter&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Reads the whole table because the query asked for it&lt;/td&gt;
 &lt;td&gt;Expected under an aggregate or a join that needs every row; it is only a problem if the query did not need every row&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;code&gt;Result&lt;/code&gt; with &lt;code&gt;One-Time Filter: false&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;Reads nothing, because the planner proved no row can match&lt;/td&gt;
 &lt;td&gt;A condition that contradicts itself&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The nodes above the leaves describe what happens to rows once they are read, and three of them decide most of a plan&amp;rsquo;s cost. &lt;code&gt;Nested Loop&lt;/code&gt; runs its second child once for every row from the first, which is where &lt;code&gt;loops&lt;/code&gt; on the inner side comes from, and it is right when the outer side is small. &lt;code&gt;Hash Join&lt;/code&gt; reads the smaller side once into memory, shown as the &lt;code&gt;Hash&lt;/code&gt; node under it, and probes it with every row of the other side; &lt;code&gt;Batches&lt;/code&gt; above 1 means the hash did not fit in memory. &lt;code&gt;Merge Join&lt;/code&gt; walks two inputs that are already in the same order, usually from an index, and needs no memory at all. Below those, &lt;code&gt;Sort&lt;/code&gt; orders rows in memory or on disk, and the line under it says which. &lt;code&gt;HashAggregate&lt;/code&gt; groups rows with a hash table and &lt;code&gt;GroupAggregate&lt;/code&gt; groups rows that already arrive sorted. &lt;code&gt;Limit&lt;/code&gt; stops pulling rows once it has enough. &lt;code&gt;Append&lt;/code&gt; glues the parts of a &lt;code&gt;UNION ALL&lt;/code&gt; together, and &lt;code&gt;CTE Scan&lt;/code&gt; reads a &lt;code&gt;WITH&lt;/code&gt; query that was run once and kept.&lt;/p&gt;
&lt;p&gt;The test data, if you want to build it yourself on PostgreSQL 18:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;span class="lnt"&gt;22
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;setseed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;C&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;lpad&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;0&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;C&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;lpad&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;0&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;-&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2022-01-01&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;interval&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;7 minutes&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;generate_series&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WITH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autovacuum_enabled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- vacuum runs only when I ask
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200000&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHEN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;01&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;THEN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;pending&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ELSE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;shipped&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2024-01-01&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;interval&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;6 seconds&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;generate_series&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5000000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;</description></item><item><title>Random UUIDs as Primary Keys: The B-Tree Penalty</title><link>https://explainanalyze.com/p/random-uuids-as-primary-keys-the-b-tree-penalty/</link><pubDate>Fri, 22 Aug 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/random-uuids-as-primary-keys-the-b-tree-penalty/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Random UUIDs as Primary Keys: The B-Tree Penalty" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;UUIDv4 primary keys are globally unique and coordination-free, and the cost is paid every time you write a row: random B-tree positions, page splits, secondary indexes bloated with 16- or 36-byte key copies, and a working set that stops fitting in the buffer pool once the table is large enough. UUIDv7 fixes the insert-locality problem (time-ordered, sortable) without changing storage size; the full fix is picking v7, storing as &lt;code&gt;BINARY(16)&lt;/code&gt; or native &lt;code&gt;uuid&lt;/code&gt;, and keeping UUIDs at the API boundary rather than internal to every join.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A table configured like this on day one looks unremarkable:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- UUIDv4, generated by the application
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Inserts are fast, reads are fast, the ORM is happy. At 100,000 rows, it&amp;rsquo;s still fine. At 10 million, the nightly ingest job gets noticeably slower. At 200 million, inserts take 50 ms each instead of 2 ms, the buffer pool is constantly churning, and the secondary indexes are three to four times the size they&amp;rsquo;d be with a &lt;code&gt;BIGINT&lt;/code&gt; primary key. Nothing about the schema changed. The table just got large enough for a design decision to start charging rent.&lt;/p&gt;
&lt;p&gt;The obvious fix is &amp;ldquo;use BIGINT auto-increment.&amp;rdquo; That&amp;rsquo;s the right answer in a lot of cases and the wrong one in others; it reintroduces coordination requirements, leaks row counts through URL-exposed IDs, and doesn&amp;rsquo;t work for schemas that need to be generated offline or across shards. UUIDs exist because those constraints are real. The sharper question is: what exactly is UUIDv4 costing you at scale, and which of those costs have cheaper alternatives?&lt;/p&gt;
&lt;h2 id="what-random-keys-do-to-a-b-tree"&gt;What random keys do to a B-tree
&lt;/h2&gt;&lt;p&gt;B-tree indexes are sorted structures. When the primary key is an auto-incrementing integer, every new row goes to the end. The rightmost leaf page is the only one that gets written to, and the rest of the index stays in cache undisturbed. Inserts are sequential and cheap.&lt;/p&gt;
&lt;p&gt;UUIDv4 is random by design. Every new row lands at a random position in the B-tree. Instead of appending to one page, the engine has to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Find the right page somewhere in the middle of the tree.&lt;/li&gt;
&lt;li&gt;Load it into the buffer pool if it isn&amp;rsquo;t already (on a large table, it usually isn&amp;rsquo;t).&lt;/li&gt;
&lt;li&gt;Split it if it&amp;rsquo;s full.&lt;/li&gt;
&lt;li&gt;Write both halves back.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;On a table with hundreds of millions of rows, the index doesn&amp;rsquo;t fit in memory, so most inserts trigger a random disk read before they can do anything else. The write amplification is real and measurable: factor of 5 to 10× versus sequential inserts isn&amp;rsquo;t unusual.&lt;/p&gt;
&lt;p&gt;The damage doesn&amp;rsquo;t stop at the primary-key index. In InnoDB (MySQL), every secondary index includes a copy of the primary key at its leaves. A 36-byte &lt;code&gt;CHAR(36)&lt;/code&gt; UUID embedded in every secondary index entry means larger indexes, more pages, more I/O compared to an 8-byte &lt;code&gt;BIGINT&lt;/code&gt;. Secondary indexes on a UUID-keyed table are routinely 3–4× the size of the same indexes on a &lt;code&gt;BIGINT&lt;/code&gt;-keyed table. Every lookup through a secondary index reads more pages to cover the same rows.&lt;/p&gt;
&lt;p&gt;PostgreSQL handles storage differently. Its heap means the primary key is just another index, so the physical table isn&amp;rsquo;t ordered by it. The primary-key index still suffers the same random-insertion pathology, and the write amplification from random page loads still applies.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Page splits compound over time.&lt;/strong&gt; When a new UUID lands in a full page, InnoDB splits the page in two, each roughly half full. Over millions of inserts, the index develops internal fragmentation: pages allocated but only partially used. The index is physically larger than it needs to be, and scans read more pages for the same row count. &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt; (MySQL) or &lt;code&gt;REINDEX&lt;/code&gt; (PostgreSQL) can repack the index, but on a busy table it&amp;rsquo;s a maintenance window you have to schedule.&lt;/p&gt;
&lt;h2 id="uuidv7-the-insert-locality-fix"&gt;UUIDv7: the insert-locality fix
&lt;/h2&gt;&lt;p&gt;UUIDv7 is the version most new code should reach for when UUIDs are the right answer. It encodes a Unix millisecond timestamp into the high 48 bits, with random bits filling the rest. Two practical consequences:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Sortable.&lt;/strong&gt; Sequential generation means new IDs land at the end of the B-tree, not scattered across it. Insert locality is close to a &lt;code&gt;BIGINT&lt;/code&gt;&amp;rsquo;s. The pathological page-split behaviour of v4 goes away.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Time-parseable.&lt;/strong&gt; The creation time is embedded in the ID, recoverable from the primary key alone: useful for log correlation, rough time-range filtering, and debugging without reaching for &lt;code&gt;created_at&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- UUIDv7: time-ordered, so inserts are roughly sequential
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- PostgreSQL 18 ships a built-in uuidv7() function
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;uuidv7&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Recover creation time from the ID - no created_at column needed
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;uuid_extract_timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;uuid_extract_timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_date&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- v7 sorts chronologically, newest first
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LIMIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;uuid_extract_timestamp()&lt;/code&gt; has existed in PostgreSQL since 17 but only returned a value for UUIDv1. PG 18 extended it to support v7 alongside the new &lt;code&gt;uuidv7()&lt;/code&gt; generator. One caveat: calling it in a &lt;code&gt;WHERE&lt;/code&gt; clause (&lt;code&gt;WHERE uuid_extract_timestamp(id) &amp;gt;= '2026-04-01'&lt;/code&gt;) is non-SARGable and forces a scan; see &lt;a class="link" href="https://explainanalyze.com/p/non-sargable-predicates-how-a-function-in-where-kills-your-index/" &gt;Non-SARGable Predicates&lt;/a&gt;. For indexed time-range filtering, keep a &lt;code&gt;created_at&lt;/code&gt; column as the query target, or compare against a boundary UUID generated at the target timestamp.&lt;/p&gt;
&lt;p&gt;MySQL 8 doesn&amp;rsquo;t ship a v7 generator or a timestamp extractor, so application-side generation is the norm there - libraries exist in every major language, and most modern ORMs default to v7 if you ask for UUIDs. Extraction is manual: for &lt;code&gt;BINARY(16)&lt;/code&gt; storage (the recommended form), the first 6 bytes hold the millisecond timestamp.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- MySQL: manually parse v7&amp;#39;s timestamp prefix (BINARY(16) storage)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;FROM_UNIXTIME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CONV&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HEX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUBSTRING&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FROM_UNIXTIME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CONV&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HEX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUBSTRING&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_date&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- v7 sorts chronologically
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LIMIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;For &lt;code&gt;CHAR(36)&lt;/code&gt; storage, the extraction strips hyphens first: &lt;code&gt;CONCAT(SUBSTRING(id, 1, 8), SUBSTRING(id, 10, 4))&lt;/code&gt; gives the 12 hex characters of the timestamp prefix. If your v1 UUIDs were stored with &lt;code&gt;UUID_TO_BIN(id, 1)&lt;/code&gt; (the swap flag that reorders bytes for v1 index locality), the byte layout differs and the substring offsets change. Most v7-generating libraries skip the swap because v7 is already time-ordered without it - check what yours does before trusting the extraction.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What v7 doesn&amp;rsquo;t change.&lt;/strong&gt; It&amp;rsquo;s still 16 bytes on disk, and still 36 if you stored it as &lt;code&gt;CHAR(36)&lt;/code&gt;. The insert-locality win doesn&amp;rsquo;t come with a storage discount, so the overhead versus a &lt;code&gt;BIGINT&lt;/code&gt; is the same as v4. The readable creation timestamp is usually a feature and occasionally a problem: in systems where row-creation time is sensitive (order IDs revealing traffic patterns to competitors, user IDs exposing signup timing), it&amp;rsquo;s the one property v4 had that v7 gives up.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;CHAR(36) is the silent tax&lt;/strong&gt;
 &lt;div&gt;The worst-case UUID storage, &lt;code&gt;CHAR(36)&lt;/code&gt;, is what most ORM-generated schemas default to, because it&amp;rsquo;s the portable representation. &lt;code&gt;BINARY(16)&lt;/code&gt; in MySQL or the native &lt;code&gt;uuid&lt;/code&gt; type in PostgreSQL cuts storage by more than half and keeps comparisons on fixed-width integers instead of strings. Pick the narrow form on day one; retrofitting it later is a full-table rewrite that touches every secondary index.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="uuid-to-integer-mapping-keep-uuids-at-the-edge"&gt;UUID-to-integer mapping: keep UUIDs at the edge
&lt;/h2&gt;&lt;p&gt;The other workable fix is structural: expose UUIDs externally, use integers internally. A single lookup table maps the external UUID to an internal &lt;code&gt;BIGINT&lt;/code&gt;, and every other table in the database uses the &lt;code&gt;BIGINT&lt;/code&gt; as its foreign key. The UUID lookup happens once (at the API boundary) and everything downstream is fast, compact, 8-byte integer joins.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;external_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BINARY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- the UUID the outside world sees
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Every other table references the BIGINT, not the UUID
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- API request comes in with a UUID; one indexed lookup to resolve it
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;external_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;UUID_TO_BIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;a1b2c3d4-...&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- From here on, everything uses the BIGINT
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The UUID column has a unique index, so the lookup is a single index seek, sub-millisecond regardless of table size. The rest of the schema gets 8-byte keys everywhere: smaller indexes, faster joins, no page splits, no secondary-index bloat. The external-facing API still uses UUIDs, so you don&amp;rsquo;t leak sequence information or row counts.&lt;/p&gt;
&lt;p&gt;The trade-off is an extra layer of indirection. Every inbound request resolves the UUID before anything else; in practice this is negligible (one indexed lookup), but it means the schema has two identity systems to maintain. For long-lived OLTP applications where every join on every table pays the UUID cost, this structure is often worth the extra lookup.&lt;/p&gt;
&lt;h2 id="when-random-uuids-are-actually-fine"&gt;When random UUIDs are actually fine
&lt;/h2&gt;&lt;p&gt;Not every schema needs to bend. Three cases where UUIDv4 as a primary key is a defensible choice:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Small tables that stay small.&lt;/strong&gt; A configuration table, a lookup table, a feature-flag table. At 50,000 rows the page-split pathology doesn&amp;rsquo;t show up, secondary indexes are tiny, and the convenience of client-generated IDs outweighs any cost.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Write rates low enough that random I/O doesn&amp;rsquo;t matter.&lt;/strong&gt; An admin tool recording 50 events per minute doesn&amp;rsquo;t care about write amplification. The index fits in cache, every page is warm, page splits happen rarely enough that fragmentation stays manageable. &amp;ldquo;Doesn&amp;rsquo;t survive scale&amp;rdquo; is only a problem at scale.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Information-leak concerns that outweigh performance.&lt;/strong&gt; If hiding creation-order is a hard requirement (competitive, privacy, or security), v7&amp;rsquo;s embedded timestamp is a non-starter and v4 is the only UUID version that meets the requirement. Pay the write-amplification cost and use the UUID-to-integer mapping to contain the damage.&lt;/p&gt;
&lt;h2 id="why-v4-keeps-showing-up-as-the-default"&gt;Why v4 keeps showing up as the default
&lt;/h2&gt;&lt;p&gt;Schema-reading assistants and scaffolding tools reinforce UUIDv4 as the default answer, and the reason is mostly inertial. Training corpora are heavy on examples where &lt;code&gt;uuid4()&lt;/code&gt; is the canonical &amp;ldquo;globally unique ID&amp;rdquo; call; &lt;code&gt;CREATE TABLE ... id UUID DEFAULT gen_random_uuid()&lt;/code&gt; appears in orders of magnitude more tutorials than the v7 equivalent. Asked for a new table schema, a model produces the v4 version because that&amp;rsquo;s what the surrounding code it learned from produced. B-tree locality and write amplification don&amp;rsquo;t show up in the DDL (they&amp;rsquo;re runtime properties of the key distribution) so the catalog gives no signal that v4 and v7 behave differently at 100M rows. Both look identical in &lt;code&gt;information_schema&lt;/code&gt;: &lt;code&gt;uuid&lt;/code&gt; or &lt;code&gt;char(36)&lt;/code&gt;, primary key, not null.&lt;/p&gt;
&lt;p&gt;The fix is the same discipline this post already makes the case for, with one amplified beat: document the choice where a schema reader can find it. A comment on the PK column (&lt;code&gt;'UUIDv7: time-ordered; required for insert locality'&lt;/code&gt; or &lt;code&gt;'UUIDv4: randomized; chosen to hide creation order'&lt;/code&gt;) turns a silent convention into a machine-readable decision. The next reader (teammate or model) sees why the column is what it is and why the alternative was rejected. Without the comment, the next table scaffolded by an assistant inherits whichever version the training data sampled, and the schema drifts toward whichever default produces the most plausible-looking DDL.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;UUIDv4 is a tool that solved a coordination problem (distributed ID generation without central authority) and accidentally became the default for everything, including the cases where coordination wasn&amp;rsquo;t a problem and the cost of random writes is non-trivial. &amp;ldquo;Pick a UUID for your PK&amp;rdquo; is a decision most schemas make without ever being explicit about what they&amp;rsquo;re trading.&lt;/p&gt;
&lt;p&gt;The decision matrix is short. Do you need globally unique, coordination-free IDs? If no, use &lt;code&gt;BIGINT&lt;/code&gt;. If yes, use UUIDv7 and store it as &lt;code&gt;BINARY(16)&lt;/code&gt; or native &lt;code&gt;uuid&lt;/code&gt;, never &lt;code&gt;CHAR(36)&lt;/code&gt;. If v7&amp;rsquo;s embedded timestamp is a problem, use v4 but keep it at the API boundary and use integers inside the schema. Each of those decisions costs almost nothing on day one and saves a lot of rework at 100 million rows.&lt;/p&gt;</description></item><item><title>Database Deadlocks, Part 1: The Patterns</title><link>https://explainanalyze.com/p/database-deadlocks-part-1-the-patterns/</link><pubDate>Thu, 13 Feb 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/database-deadlocks-part-1-the-patterns/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Database Deadlocks, Part 1: The Patterns" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;A deadlock is two transactions each holding a lock the other needs, caught in a cycle the engine breaks by killing one. The patterns are finite and repeatable: inconsistent lock ordering across workers, InnoDB gap locks under &lt;code&gt;REPEATABLE READ&lt;/code&gt;, foreign-key shared locks on hot parent rows, unique-index conflicts, index-scan lock amplification, and parallelism patterns that only surface on replicas or under worker-pool load. This post is the patterns. &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/" target="_blank" rel="noopener"
 &gt;Part 2&lt;/a&gt; covers diagnosis, retry architecture, and prevention.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Deadlocks occupy a strange place in production operations. They&amp;rsquo;re rare enough that most engineers haven&amp;rsquo;t thought hard about them, frequent enough in high-concurrency workloads to show up as paging incidents, and subtle enough that the first instinct (&amp;ldquo;just retry&amp;rdquo;) is right often enough to keep the root cause hidden. The transaction that got killed was syntactically perfect. The one that survived was too. The bug wasn&amp;rsquo;t in either statement; it was in the order the two transactions touched rows.&lt;/p&gt;
&lt;p&gt;That makes deadlocks harder to reason about than most database failures. The query text in the error log isn&amp;rsquo;t wrong. The lock it was waiting for isn&amp;rsquo;t held by a misbehaving process. The system is doing exactly what concurrency control says it should. The failure mode is the interaction between transactions, and those interactions are almost never visible from any single query.&lt;/p&gt;
&lt;p&gt;This post covers the patterns: the shapes deadlocks take and why each one exists. The &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/" &gt;companion post&lt;/a&gt; covers reading the deadlock log end-to-end, retry architecture, hot-row isolation, SERIALIZABLE&amp;rsquo;s serialization failures, DDL migration windows, and &lt;code&gt;NOWAIT&lt;/code&gt; / &lt;code&gt;SKIP LOCKED&lt;/code&gt; as prevention primitives.&lt;/p&gt;
&lt;h2 id="what-a-deadlock-actually-is"&gt;What a deadlock actually is
&lt;/h2&gt;&lt;p&gt;A deadlock is a cycle in the wait-for graph. Transaction A holds lock L1 and needs L2; transaction B holds L2 and needs L1. Neither can proceed. The only way out is to kill one: pick a victim, roll it back, release its locks, and let the other complete. Every modern relational database does this automatically, usually within hundreds of milliseconds.&lt;/p&gt;
&lt;p&gt;The three preconditions are always the same:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Two or more transactions hold locks.&lt;/li&gt;
&lt;li&gt;Each needs a lock the other holds.&lt;/li&gt;
&lt;li&gt;The locks can&amp;rsquo;t be acquired atomically (there&amp;rsquo;s no single &amp;ldquo;grab both or grab nothing&amp;rdquo; operation).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Remove any one and the deadlock can&amp;rsquo;t form. In practice, that&amp;rsquo;s the shape of every prevention strategy: reduce the number of locks held concurrently, reduce the duration they&amp;rsquo;re held, or make the acquisition order consistent across all code paths.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Deadlock vs. lock wait timeout&lt;/strong&gt;
 &lt;div&gt;A deadlock is a cycle. A lock wait timeout is a long queue: transaction A is waiting for transaction B, which is waiting for something reasonable, which is taking too long. No cycle, no victim selection, just a timer expiring. Both produce errors that look similar in application logs, but they&amp;rsquo;re entirely different failure modes with different fixes. &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_lock_wait_timeout" target="_blank" rel="noopener"
 &gt;&lt;code&gt;innodb_lock_wait_timeout&lt;/code&gt;&lt;/a&gt; (MySQL) and &lt;a class="link" href="https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-LOCK-TIMEOUT" target="_blank" rel="noopener"
 &gt;&lt;code&gt;lock_timeout&lt;/code&gt;&lt;/a&gt; (PostgreSQL) govern the second. The deadlock detector is a separate mechanism that fires independently of those timers.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="the-canonical-lock-ordering-deadlock"&gt;The canonical lock-ordering deadlock
&lt;/h2&gt;&lt;p&gt;The single most common production deadlock is two transactions updating the same two rows in opposite orders:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Transaction A
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Transaction B (concurrent)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;If A and B interleave such that A takes the row-level lock on row 1, B takes the row-level lock on row 2, and each then tries to grab the other row, the engine has a cycle. One gets killed.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;sequenceDiagram
 participant A as Transaction A
 participant R1 as Row id=1
 participant R2 as Row id=2
 participant B as Transaction B

 Note over A,B: t=0, both transactions begin
 A-&gt;&gt;R1: UPDATE (acquire X-lock)
 R1--&gt;&gt;A: granted
 B-&gt;&gt;R2: UPDATE (acquire X-lock)
 R2--&gt;&gt;B: granted

 Note over A,B: t=1, each reaches for the other's row
 A-&gt;&gt;R2: UPDATE (request X-lock)
 R2--xA: BLOCKED (held by B)
 B-&gt;&gt;R1: UPDATE (request X-lock)
 R1--xB: BLOCKED (held by A)

 Note over A,B: Wait-for graph has a cycle: A → B → A
 Note over A,B: Detector fires; victim: Transaction B
 B-&gt;&gt;B: ROLLBACK, release R2 lock
 R2--&gt;&gt;A: now granted
 A-&gt;&gt;A: COMMIT&lt;/pre&gt;&lt;p&gt;The key property is that neither transaction is wrong in isolation. Each acquires locks in an order that&amp;rsquo;s locally correct. The cycle forms in the global ordering across concurrent sessions, which no single query can see. That&amp;rsquo;s the defining shape of the pattern: correct code, in both cases, interacting at the transaction boundary. The fix is in how all code paths agree on an ordering, covered in &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/#consistent-lock-ordering-the-highest-leverage-fix" &gt;Part 2: Consistent lock ordering&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="innodb-gap-locks-turn-inserts-into-deadlock-sources"&gt;InnoDB gap locks turn inserts into deadlock sources
&lt;/h2&gt;&lt;p&gt;MySQL&amp;rsquo;s default isolation level is &lt;code&gt;REPEATABLE READ&lt;/code&gt;, and under that isolation level, InnoDB takes next-key locks: a row lock plus a gap lock on the range before it. The gap lock prevents other transactions from inserting into that range, which is how &lt;code&gt;REPEATABLE READ&lt;/code&gt; keeps range queries consistent across re-execution.&lt;/p&gt;
&lt;p&gt;The consequence: a &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; or an &lt;code&gt;UPDATE&lt;/code&gt; with a range predicate locks not just the matching rows, but the gaps between them. Two concurrent transactions that both try to insert into the same gap can deadlock without sharing a single row:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Table: orders(id BIGINT PK, customer_id BIGINT, amount_cents BIGINT)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Index on customer_id
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Existing rows: customer_id = 5 has orders with ids 100, 200, 300
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Transaction A
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FOR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Takes next-key locks covering ids 100, 200, 300 AND the gaps between them,
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- AND the gap after 300 extending to the next customer_id.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Transaction B (concurrent)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Blocks: tries to insert into a gap A has locked.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Transaction A
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Deadlock if B has also started acquiring locks that A now needs.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Two transactions inserting into what looks like &amp;ldquo;different rows&amp;rdquo; can still cycle through gap locks. The failure mode is especially insidious because the &lt;code&gt;EXPLAIN&lt;/code&gt; plan doesn&amp;rsquo;t mention gaps - only rows - and the lock information in &lt;code&gt;SHOW ENGINE INNODB STATUS&lt;/code&gt; requires reading the next-key notation carefully.&lt;/p&gt;
&lt;p&gt;The category is peculiar to InnoDB under &lt;code&gt;REPEATABLE READ&lt;/code&gt;. PostgreSQL prevents phantom reads through MVCC snapshot isolation starting at its own &lt;code&gt;REPEATABLE READ&lt;/code&gt; level (stricter than the SQL standard requires) without any range-locking mechanism, so the whole class of gap-lock deadlocks doesn&amp;rsquo;t exist on PG, at any isolation level. Under &lt;code&gt;READ COMMITTED&lt;/code&gt; on MySQL, gap locks are disabled for most searches and index scans but retained for foreign-key and duplicate-key checking, which is the first lever most teams reach for once this pattern dominates their deadlocks, though it doesn&amp;rsquo;t eliminate the gap-lock category entirely. The isolation-level trade-off and the &amp;ldquo;avoid range locks on write paths&amp;rdquo; refactor both live in &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/#isolation-level-trade-offs" &gt;Part 2: Isolation-level trade-offs&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="unique-index-deadlocks-are-a-category-of-their-own"&gt;Unique-index deadlocks are a category of their own
&lt;/h2&gt;&lt;p&gt;The detailed patterns are covered in &lt;a class="link" href="https://explainanalyze.com/p/uniqueness-and-selectivity-the-two-numbers-that-drive-query-plans/#unique-indexes-also-concentrate-deadlock-pressure" &gt;Uniqueness and Selectivity&lt;/a&gt;, but the shape worth naming here: when InnoDB detects a duplicate-key error on an &lt;code&gt;INSERT&lt;/code&gt;, it acquires a shared lock on the conflicting index record before raising the error. Under &lt;code&gt;REPEATABLE READ&lt;/code&gt; that shared lock is next-key (record + gap). Under &lt;code&gt;READ COMMITTED&lt;/code&gt;, gap locks are mostly disabled, but duplicate-key checking is one of the documented exceptions where gap locking still occurs, so dropping isolation alone doesn&amp;rsquo;t eliminate the category. Two concurrent transactions inserting toward the same unique key end up holding shared locks and waiting for each other to release: a deadlock caused entirely by the uniqueness check, not by the rows the application thought it was writing.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt; behaves differently: on conflict it takes an exclusive lock instead of a shared one, because the statement is about to modify the row. This matters for reasoning about cycles. Two concurrent &lt;code&gt;ODKU&lt;/code&gt; statements contend on exclusive locks (mutually exclusive, one always waits), whereas two concurrent plain &lt;code&gt;INSERT&lt;/code&gt;s can both hold shared locks at once and then deadlock when either tries to upgrade. Blog posts and older documentation sometimes conflate the two; the locking rules are documented in the &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html" target="_blank" rel="noopener"
 &gt;MySQL reference: Locks Set by Different SQL Statements in InnoDB&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The equivalent in PostgreSQL is less severe (the duplicate-key check doesn&amp;rsquo;t hold long-lived shared locks the same way) but &lt;code&gt;INSERT ... ON CONFLICT&lt;/code&gt; with multiple unique indexes can still produce deadlocks when batches touch overlapping keys in different orders. The shape is the same across engines: the uniqueness check itself is what forces the extra locking, and the cycle forms when two sessions approach the same key from different batches.&lt;/p&gt;
&lt;h2 id="foreign-keys-take-shared-locks-you-didnt-ask-for"&gt;Foreign keys take shared locks you didn&amp;rsquo;t ask for
&lt;/h2&gt;&lt;p&gt;Both MySQL and PostgreSQL acquire shared locks on the referenced row when you insert or update a row with a foreign key. The purpose is to prevent the referenced row from being deleted mid-transaction; you can&amp;rsquo;t have an &lt;code&gt;orders.customer_id&lt;/code&gt; pointing to a &lt;code&gt;customers.id&lt;/code&gt; that&amp;rsquo;s being concurrently deleted.&lt;/p&gt;
&lt;p&gt;The side effect is that a high-write child table concentrates shared locks on hot parent rows:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- customers has id = 42 (a frequently-used customer)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Many concurrent transactions inserting orders for customer 42:
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Takes a shared lock on customers(id=42)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Shared locks don&amp;rsquo;t block each other, so concurrent inserts coexist fine. What breaks is the interaction with any transaction that wants an exclusive lock on the parent row: an update to the customer&amp;rsquo;s name, a soft-delete, a trigger that updates a cached counter. Suddenly, dozens of shared-lock holders are blocking one exclusive-lock request, and if any of them start trying to acquire other locks (say, through a trigger cascade), a cycle can form.&lt;/p&gt;
&lt;p&gt;The symptoms: deadlocks that mention tables far removed from the one the application thought it was touching. &amp;ldquo;Why is my &lt;code&gt;UPDATE customers&lt;/code&gt; deadlocking against an &lt;code&gt;INSERT INTO order_items&lt;/code&gt;?&amp;rdquo; Because the &lt;code&gt;order_items&lt;/code&gt; insert took a shared lock on &lt;code&gt;customers&lt;/code&gt; through the FK chain, and the &lt;code&gt;UPDATE&lt;/code&gt; wanted exclusive on the same row.&lt;/p&gt;
&lt;p&gt;This is one of the hardest patterns to diagnose on sight, because the offending query never references the contended table explicitly. Mitigations (narrowing FK scope, moving hot parent-row updates to side tables, isolation-level trade-offs) are covered in &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/#hot-row-isolation-removing-the-pattern-instead-of-retrying-it" &gt;Part 2: Hot-row isolation&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="index-scans-lock-more-rows-than-queries-return"&gt;Index scans lock more rows than queries return
&lt;/h2&gt;&lt;p&gt;Under InnoDB&amp;rsquo;s default &lt;code&gt;REPEATABLE READ&lt;/code&gt;, an &lt;code&gt;UPDATE&lt;/code&gt; with a &lt;code&gt;WHERE&lt;/code&gt; clause on a non-indexed column acquires a record lock on every row it scans, not just the ones that match. The engine has to examine each row to check the predicate, and it takes a lock to guarantee the check is stable for the duration of the transaction.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Without an index on status, under REPEATABLE READ:
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;high&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;pending&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Locks every row in orders during the scan.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;If the table has a million rows and only a thousand match, all million get locked for the duration of the update. Any concurrent transaction touching any of those rows has to wait, which inflates the wait-for graph and makes deadlocks more likely.&lt;/p&gt;
&lt;p&gt;Under &lt;code&gt;READ COMMITTED&lt;/code&gt;, InnoDB narrows this substantially: &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html" target="_blank" rel="noopener"
 &gt;per the docs&lt;/a&gt;, it releases locks on non-matching rows after the &lt;code&gt;WHERE&lt;/code&gt; evaluation and uses semi-consistent reads, returning the latest committed version of an already-locked row so the engine can check whether it matches the &lt;code&gt;WHERE&lt;/code&gt; before deciding to wait. The net effect is much lower lock footprint and deadlock risk on the same query. PostgreSQL behaves similarly by default: only rows actually updated retain their locks. This is one of the few cases where the same underlying issue (an unindexed predicate) shows up as both a latency problem and a concurrency problem, and where the concurrency angle is specifically a &lt;code&gt;REPEATABLE READ&lt;/code&gt;-on-InnoDB amplifier.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Secondary index locks on InnoDB&lt;/strong&gt;
 &lt;div&gt;InnoDB takes locks on both the clustered index (primary key) and any secondary indexes touched by the query. A &lt;code&gt;WHERE status = 'pending'&lt;/code&gt; using a &lt;code&gt;status&lt;/code&gt; index locks the relevant index entries and the corresponding PK entries. Transactions that approach the same rows from different indexes (one via &lt;code&gt;status&lt;/code&gt;, another via &lt;code&gt;customer_id&lt;/code&gt;) can deadlock on the PK-side lock even though their index-side locks don&amp;rsquo;t overlap. This is the most common &amp;ldquo;why are these two queries deadlocking, they don&amp;rsquo;t even reference the same columns?&amp;rdquo; failure mode.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="parallelism-induced-deadlocks"&gt;Parallelism-induced deadlocks
&lt;/h2&gt;&lt;p&gt;The lock-ordering patterns above assume two separate transactions from two separate sessions. Parallelism adds a few variants that don&amp;rsquo;t fit that frame; the cycle can form inside a single logical unit of work, or show up only on a replica that never issued the original statements.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Worker pools racing on a shared queue.&lt;/strong&gt; The archetypal production pattern: N application workers pulling jobs from the same table (&lt;code&gt;jobs&lt;/code&gt;, &lt;code&gt;outbox&lt;/code&gt;, &lt;code&gt;email_queue&lt;/code&gt;) and locking rows for processing. If every worker does &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; on &amp;ldquo;the next available batch&amp;rdquo; without a deterministic ordering, two workers can grab overlapping row sets in opposite orders and cycle. This is the same lock-ordering cycle from earlier, distributed across workers that all look identical from a code-review perspective.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Intra-query parallel workers.&lt;/strong&gt; PostgreSQL has a full parallel query executor (parallel sequential scans, bitmap heap scans, index and index-only scans (B-tree), parallel aggregates, parallel joins) that spawns worker processes to cooperate on a single query. MySQL has a much narrower feature: &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_parallel_read_threads" target="_blank" rel="noopener"
 &gt;&lt;code&gt;innodb_parallel_read_threads&lt;/code&gt;&lt;/a&gt; (added in 8.0.14) enables parallel scanning of the clustered index, used initially by &lt;code&gt;CHECK TABLE&lt;/code&gt; and extended to unconditional &lt;code&gt;SELECT COUNT(*)&lt;/code&gt; in 8.0.17. It is not general parallel query; MySQL does not parallelize arbitrary &lt;code&gt;SELECT&lt;/code&gt;s, joins, or aggregates. In both engines, workers coordinating on a single query don&amp;rsquo;t deadlock among themselves in normal operation; the engine manages the shared lock state. What can happen is a parallel worker holds a lock an unrelated transaction needs, and the parallel query itself takes longer than a serial one would, widening the wait window. Usually not a direct deadlock source, but it changes the timing of existing ones.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Parallel replication on replicas.&lt;/strong&gt; MySQL&amp;rsquo;s multi-threaded replica applies committed transactions in parallel. Transactions that committed serially on the source (no possibility of deadlock there) can deadlock on the replica because the applier threads are racing on rows the source never had concurrent writers on. The replica&amp;rsquo;s deadlock detector resolves them the same way it would a live deadlock, but they show up in the replica&amp;rsquo;s error log with no corresponding entry on the source. Since MySQL 8.0.27, &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/replication-options-replica.html#sysvar_replica_parallel_type" target="_blank" rel="noopener"
 &gt;&lt;code&gt;replica_parallel_type&lt;/code&gt;&lt;/a&gt;&lt;code&gt;=LOGICAL_CLOCK&lt;/code&gt; and &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/replication-options-replica.html#sysvar_replica_parallel_workers" target="_blank" rel="noopener"
 &gt;&lt;code&gt;replica_parallel_workers&lt;/code&gt;&lt;/a&gt;&lt;code&gt;=4&lt;/code&gt; are the defaults, and &lt;code&gt;replica_parallel_type&lt;/code&gt; was deprecated in 8.0.29; &lt;code&gt;LOGICAL_CLOCK&lt;/code&gt; is effectively the only supported mode going forward. The &lt;code&gt;slave_*&lt;/code&gt; → &lt;code&gt;replica_*&lt;/code&gt; rename happened in 8.0.26; older deployments and blog posts still use the legacy names. PostgreSQL 16+ introduced parallel apply for logical replication (&lt;code&gt;streaming = parallel&lt;/code&gt; is the default on &lt;code&gt;CREATE SUBSCRIPTION&lt;/code&gt;), which exposes the same class of apply-side cycles on a setup that historically didn&amp;rsquo;t have them: a surprise for teams upgrading from 15 and earlier.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Parallel/online DDL interacting with DML.&lt;/strong&gt; Tools like &lt;code&gt;pt-online-schema-change&lt;/code&gt; and &lt;code&gt;gh-ost&lt;/code&gt; run concurrent DML against the table being altered (through triggers or a row-copy process). Under load, the trigger-installed writes and the copy process can both take locks on the same rows the application is updating, and the wait-for graph gains edges that wouldn&amp;rsquo;t exist during a normal workload. This rarely manifests as a hard deadlock (the tools are written defensively) but it does manifest as elevated deadlock rates during the migration window.&lt;/p&gt;
&lt;p&gt;None of these are properties of the queries themselves. They&amp;rsquo;re properties of how work gets distributed across workers, processes, or replicas, which means they&amp;rsquo;re invisible to query-level review and only surface when the deadlock counter is watched over time. The primitives for fixing them (&lt;code&gt;SKIP LOCKED&lt;/code&gt;, &lt;code&gt;NOWAIT&lt;/code&gt;, advisory locks, DDL timeouts) are covered in &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/#nowait-and-skip-locked-as-prevention-primitives" &gt;Part 2: NOWAIT and SKIP LOCKED&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="engine-level-differences-that-shape-the-patterns"&gt;Engine-level differences that shape the patterns
&lt;/h2&gt;&lt;p&gt;The same pattern can deadlock on one engine and not the other. These differences are pattern-shaping; they change which of the above sections apply to your workload. Operational tuning (detector cost, wait timeouts, monitoring) is covered in &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/#monitoring-that-actually-catches-regressions" &gt;Part 2: Monitoring&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Default isolation.&lt;/strong&gt; PostgreSQL defaults to &lt;code&gt;READ COMMITTED&lt;/code&gt;. MySQL defaults to &lt;code&gt;REPEATABLE READ&lt;/code&gt; (with gap locks). The same application code has measurably different deadlock rates between the two because of this alone, before any other tuning.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gap locks.&lt;/strong&gt; Only InnoDB has them, and only under &lt;code&gt;REPEATABLE READ&lt;/code&gt; (plus the foreign-key and duplicate-key exceptions that retain gap locking even under &lt;code&gt;READ COMMITTED&lt;/code&gt;). PostgreSQL prevents phantom reads through MVCC at its own &lt;code&gt;REPEATABLE READ&lt;/code&gt; (stricter than the SQL standard requires) without a range-locking mechanism, so the entire gap-lock deadlock category doesn&amp;rsquo;t exist on PG at any isolation level.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lock granularity.&lt;/strong&gt; PostgreSQL takes row-level (tuple) locks; InnoDB takes record locks on index entries (with next-key extension under &lt;code&gt;REPEATABLE READ&lt;/code&gt;). The practical consequence is that InnoDB locks are more entangled with index choice than PostgreSQL&amp;rsquo;s; changing which index a query uses can change which rows and gaps get locked.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FK lock style.&lt;/strong&gt; MySQL&amp;rsquo;s FK check holds a shared lock on the referenced row (next-key under &lt;code&gt;REPEATABLE READ&lt;/code&gt;, and the docs list FK checking as one of the places gap locks persist even under &lt;code&gt;READ COMMITTED&lt;/code&gt;). PostgreSQL takes a &lt;code&gt;FOR KEY SHARE&lt;/code&gt; lock (added in 9.3 specifically to reduce FK lock contention vs. the older &lt;code&gt;FOR SHARE&lt;/code&gt;). Hot parent rows are more contended under MySQL as a result.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Row-lock visibility.&lt;/strong&gt; PostgreSQL row-level locks don&amp;rsquo;t show up in &lt;code&gt;pg_locks&lt;/code&gt;. Per the docs, they&amp;rsquo;re stored on the tuple header on disk, not in shared memory. A process waiting for a row lock usually appears in &lt;code&gt;pg_locks&lt;/code&gt; as waiting for the holder&amp;rsquo;s transaction ID, not the row. InnoDB&amp;rsquo;s &lt;code&gt;performance_schema.data_locks&lt;/code&gt; exposes row-level lock state directly. More on this in Part 2.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Neither engine is &amp;ldquo;better.&amp;rdquo; The behaviors are different, and code that assumes one can deadlock mysteriously when moved to the other.&lt;/p&gt;
&lt;h2 id="why-schema-reading-assistants-hit-these-patterns"&gt;Why schema-reading assistants hit these patterns
&lt;/h2&gt;&lt;p&gt;Locking behavior has no syntax in the query text. A &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; advertises the intent; a plain &lt;code&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt; or &lt;code&gt;INSERT ... ON CONFLICT&lt;/code&gt; doesn&amp;rsquo;t. The shared next-key lock on a duplicate-key violation, the FK shared lock on the parent row, the gap-lock extension under MySQL&amp;rsquo;s default isolation are all implementation details of the storage engine. Schema-reading assistants read the catalog, which describes tables, columns, and constraints, and the codebase, which describes queries. Neither surfaces lock ordering, gap-lock semantics, or the difference between &lt;code&gt;READ COMMITTED&lt;/code&gt; and &lt;code&gt;REPEATABLE READ&lt;/code&gt; unless the prompt specifically includes them.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s why AI-generated UPSERT and batch-insert code deadlocks in production the way it does. The model reads &lt;code&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt; as an atomic upsert, not as &amp;ldquo;takes a shared next-key lock, possibly including a gap, before raising the duplicate-key error that the application will retry.&amp;rdquo; It generates batch INSERTs that process rows in whatever order the application supplies, not sorted by key: fine for a single writer, a lock-ordering cycle under any realistic concurrency. The patterns above are the ones the catalog and query text can&amp;rsquo;t warn about, and they&amp;rsquo;re the ones that arrive in production as &amp;ldquo;intermittent deadlocks under load&amp;rdquo; after passing every test that didn&amp;rsquo;t include a second concurrent worker. The fix lives one level up from the query (sorted batches, explicit lock ordering, retry loops) which is the subject of Part 2.&lt;/p&gt;
&lt;h2 id="whats-in-part-2"&gt;What&amp;rsquo;s in Part 2
&lt;/h2&gt;&lt;p&gt;The patterns are the first half. Turning them into working systems takes a different set of skills: reading the deadlock log to identify which pattern is firing, building retry logic that doesn&amp;rsquo;t mask the real bug, isolating hot rows before they become incident reports, and choosing the right tool for each (&lt;code&gt;NOWAIT&lt;/code&gt;, &lt;code&gt;SKIP LOCKED&lt;/code&gt;, advisory locks, counter tables, or the isolation-level change that eliminates the category entirely). PostgreSQL&amp;rsquo;s &lt;code&gt;SERIALIZABLE&lt;/code&gt;/SSI produces serialization failures that look like deadlocks but aren&amp;rsquo;t; the difference matters for retry architecture. &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; and sequence-related locking have their own failure modes. DDL migrations on both engines introduce lock queues that manifest as deadlock-like incidents.&lt;/p&gt;
&lt;p&gt;All of that is in &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/" &gt;Database Deadlocks, Part 2: Diagnosis, Retries, and Prevention&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="mental-model-for-the-patterns"&gt;Mental model for the patterns
&lt;/h2&gt;&lt;p&gt;Deadlocks are what consistent concurrency control does when two transactions make the engine choose between them. The database isn&amp;rsquo;t misbehaving; it&amp;rsquo;s refusing to let both of two contradictory orderings win. The error in the application log is a notification, not a fault.&lt;/p&gt;
&lt;p&gt;That makes the diagnostic question concrete. Which pattern is firing? Every deadlock in production fits one of the shapes above: lock-ordering cycle, gap lock on a range, duplicate-key shared lock, FK shared lock on a hot parent, unindexed predicate lock amplification, worker-pool race, or replication-apply cycle. Identifying the pattern from the deadlock log narrows the fix enormously. &amp;ldquo;Two transactions deadlocked, retry the transaction&amp;rdquo; is true but useless. &amp;ldquo;Two workers took locks on the same jobs queue in different orders, switch to &lt;code&gt;SKIP LOCKED&lt;/code&gt;&amp;rdquo; is a fix. The work is in the identification.&lt;/p&gt;</description></item></channel></rss>