Join Order for Three or More Tables
The same rows, very different amounts of work: join the selective tables first
For inner joins over analysed tables, try every left-deep order of the FROM's entries, skip orders that make a cross product, price each with the join estimates and costs of the previous stage, and keep the cheapest, without changing the columns or the rows.
- Joins of inner tables can be done in any order and give the same rows
- The order decides how big every intermediate result is
- Left-deep plans: each join adds one table
- Never join two tables with nothing linking them while a linked order exists
- LEFT JOINs do not commute, so they are not reordered
Why this stage exists
Sales of category t3: 2000 sales, 200 products, 20 categories. Written as sale join prod join cat where c.tag = 't3', the obvious plan joins every sale to its product, making 2000 rows, then throws away nineteen twentieths of them when it reaches the categories. Started from the other end, the filter leaves one category, which has 10 products, which have 100 sales. The rows are the same and the work is a tiny fraction.
The previous stage chose each join's algorithm. This one chooses which tables to join first.
Left-deep orders
A left-deep plan adds one table at each join: the first two tables, then that result with the third, and so on. Every right child is a single table, which is what an index nested loop needs. With three tables there are six orders, and with four there are 24.
For each order, place the conditions again (with inner joins, ON and WHERE mean the same), choose each join's algorithm from the bottom up as before, and take the cost of the top join. The cheapest order wins.
No cross products
In x join y on x.k = y.k join z on y.m = z.m, nothing links x and z. An order that joins them first makes every pair of their rows, a cross product, only to filter most of them away at the next join. Orders like that are skipped, as long as some order avoids them. With only a few small tables a cross product can occasionally be cheaper, but real planners avoid them for the same reason this one does: the estimates are least reliable exactly there.
What does not move
Columns belong to their entries, not to positions, so select * still lists x's columns, then y's, then z's, whatever order the plan joins in. The rows are the same; only their order without an ORDER BY can change.
A LEFT JOIN is not reordered: a left join b keeps every row of a, and swapping the sides would keep every row of b instead. A FROM of two tables is joined as written, and so is a FROM with any LEFT JOIN, as is any statement with a table that has no statistics, or a forced --join.
The algorithm in words
Choosing the join order
Try every left-deep order, keep the cheapest.
- 1.Reorder only a FROM of three entries or more, when every join is inner, every table has statistics and --join is auto. Otherwise join as written.
- 2.For each permutation of the entries, check that every entry after the first shares a conjunct with the entries before it. Skip the permutation if not, unless every permutation fails that test.
- 3.Place the conjuncts for this order: each at the scan of the one entry it reads, or at the lowest join that has all its entries.
- 4.Build the joins bottom up, choosing each one's algorithm and estimate as in the previous stage. The order's cost is the top join's cost.
- 5.Keep the cheapest order. On a tie, keep the one that comes first when orders are compared by the FROM positions of their entries.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, section 74.
[PLAN] select * from x join y on x.k = y.k join z on y.m = z.m
[PLAN] └── NestedLoopJoin (on: x.k = y.k) (rows=6 cost=9)
[PLAN] ├── NestedLoopJoin (on: y.m = z.m) (rows=6 cost=3)
[PLAN] │ ├── SeqScan (table=z) (rows=2 cost=1)
[PLAN] │ └── SeqScan (table=y) (rows=6 cost=1)
[PLAN] └── SeqScan (table=x) (rows=4 cost=1)
What you should see when done
$droid --db order.dbWelcome to droid!>create table x (id int primary key, k int);Create table statement executed successfully>create table y (id int primary key, k int, m int);Create table statement executed successfully>create table z (id int primary key, m int);Create table statement executed successfully>insert into x (id, k) values (1, 1);Insert statement executed successfully (1 rows)>insert into x (id, k) values (2, 1);Insert statement executed successfully (1 rows)>insert into y (id, k, m) values (1, 1, 1);Insert statement executed successfully (1 rows)>insert into z (id, m) values (1, 1);Insert statement executed successfully (1 rows)>analyze x;Analyze statement executed successfully>analyze y;Analyze statement executed successfully>analyze z;Analyze statement executed successfully>explain select * from x join y on x.k = y.k join z on y.m = z.m;[PLAN] select * from x join y on x.k = y.k join z on y.m = z.m[PLAN] └── NestedLoopJoin (on: x.k = y.k) (rows=2 cost=3)[PLAN] ├── NestedLoopJoin (on: y.m = z.m) (rows=1 cost=2)[PLAN] │ ├── SeqScan (table=y) (rows=1 cost=1)[PLAN] │ └── SeqScan (table=z) (rows=1 cost=1)[PLAN] └── SeqScan (table=x) (rows=2 cost=1)>.exitexiting.. good bye!
As written, x then y then z costs 5. Starting from y, as y, z, x, costs 3, and so does z, y, x; on that tie, y, z, x wins, because its FROM positions (y second, z third, x first) come before z, y, x.
Common bugs in this stage
- Reordering a LEFT JOIN. It changes the rows.
- Placing conditions once, for the written order. Each order needs its own placement.
- Reordering the columns of
select *. Columns stay with their entries. - Picking a cross product when a linked order exists.
- Breaking ties by something other than FROM positions. The written order wins a tie.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C: generate permutations in lexicographic order with the classic next-permutation step on an array of entry indexes; the first one is the written order.
- C++:
std::next_permutationon a sorted vector of indexes visits them in exactly the tie-break order. - Rust: a small recursive generator over a
Vec<usize>, or the next-permutation algorithm on a slice. - Zig: the next-permutation algorithm on a
[]usizeslice, starting from the identity.
Checklist
One line per thing the tests check. The tags name the test cases.
- Every left-deep order is priced, cross products skipped, and the cheapest kept, with exact estimatesintermediate-estimate
- Reordering the joins changes neither the columns of select * nor the rowscolumns-keep-from-order
- Three tables written worst-first, with a selective filter on the last: the joins start from itsmallest-first
- Every order of the FROM list gives the same rows, and the same planall-permutations-same-rows
- Two tables with no condition between them are never joined to each other directlyno-early-cross-product
- A FROM with a LEFT JOIN keeps the order writtenleft-join-not-reordered
- Without statistics the joins stay in the order writtenno-stats-written-order