Your country

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

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

Database Management Systems (DBMS)  Module 10 – Query processing and optimisation

Cost-based optimisation and join ordering

How an optimiser prices candidate plans from estimated rows, why the join order decides the cost, and how dynamic programming finds the lowest-cost order.

  • Advanced
  • 35 minutes
  • Examples run with sql.js 1.14.2, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 12 of 35 minutes free
Free: Why join ordering decides the cost, with numbers · Dynamic programming over subsets, in brief · SQLite reorders joins; CROSS JOIN fixes the order · 2 more sections. With a pass: 2 in-depth parts and 3 interview answers, about 23 minutes.

What you will learn

  • Count left-deep and bushy join orders and explain why they cannot all be tried
  • Run Selinger-style dynamic programming over given cardinalities and costs
  • Compare dynamic programming with greedy search and left-deep with bushy plans, using numbers
  • Explain how PostgreSQL limits the search with join_collapse_limit and GEQO, and how it caches plans

Before you start

What you’ll learn · Before you start

What you will learn

  • Count left-deep and bushy join orders and explain why they cannot all be tried
  • Run Selinger-style dynamic programming over given cardinalities and costs
  • Compare dynamic programming with greedy search and left-deep with bushy plans, using numbers
  • Explain how PostgreSQL limits the search with join_collapse_limit and GEQO, and how it caches plans

Before you start

On this page

Cost-based optimisation chooses a plan by pricing the alternatives: for every candidate plan the optimiser estimates the rows each step will produce, turns them into a cost with its cost model, and keeps the cheapest. The candidates differ in the access path for each table, the method of each join and, the largest part of the search, in join ordering, the order in which the tables are joined. Intermediate results can differ by factors of thousands between orders, and the number of orders grows faster than exponentially, so no optimiser tries them all. The standard method comes from IBM’s System R, published by Selinger and colleagues in 1979: dynamic programming over subsets of tables. PostgreSQL searches near-exhaustively below 12 FROM items and switches to a genetic search at 12; SQLite runs a bounded search of its own.

Why join ordering decides the cost, with numbers

A placement portal stores students, their applications, jobs and the companies that offer them. A query asks for the 2027 batch’s applications to SDE jobs at Bengaluru companies: after their filters, 12,500 students, 400,000 applications, 200 jobs and 60 companies, linked in a chain. The program counts the possible orders, then prices each left-deep order of the four tables by C_out, the rows every intermediate result holds:

How many join orders there are, and what each one costs Python · join_orders.py
# How many join orders there are, and how much the choice matters, for a
# placement portal: students apply to jobs offered by companies.
from itertools import permutations
from math import factorial

print(f"{'tables':>6}{'left-deep orders':>20}{'all join trees':>28}")
for n in (2, 3, 4, 5, 6, 8, 10, 12, 15):
    bushy = factorial(2 * n - 2) // factorial(n - 1)        # binary trees with ordered leaves
    print(f"{n:>6}{factorial(n):>20,}{bushy:>28,}")

# Table sizes after their filters, and the selectivity of each join condition.
rows = {"S": 50_000 * 0.25,      # students of the 2027 batch
        "A": 400_000,            # applications
        "J": 2_000 * 0.10,       # jobs for the SDE role
        "C": 300 * 0.20}         # companies in Bengaluru
join_sel = {frozenset("SA"): 1 / 50_000, frozenset("AJ"): 1 / 2_000, frozenset("JC"): 1 / 300}


def size(tables):
    """Estimated rows of the join of these tables: product of sizes and of the selectivities that apply."""
    n = 1.0
    for t in tables:
        n *= rows[t]
    for pair, s in join_sel.items():
        if pair <= set(tables):
            n *= s
    return n


def cost(order):
    """C_out: the rows of every intermediate result a left-deep plan builds."""
    return sum(size(order[:k]) for k in range(2, len(order) + 1))


orders = sorted(permutations("SAJC"), key=cost)
print(f"\n{len(orders)} left-deep orders for S JOIN A JOIN J JOIN C; rows built by each plan:")
for o in orders[:3] + orders[-3:]:
    print(f"  {' -> '.join(o)}{cost(o):>18,.0f}")
print(f"the worst builds {cost(orders[-1]) / cost(orders[0]):,.0f} times as many rows as the best")

Output

tables    left-deep orders              all join trees
     2                   2                           2
     3                   6                          12
     4                  24                         120
     5                 120                       1,680
     6                 720                      30,240
     8              40,320                  17,297,280
    10           3,628,800              17,643,225,600
    12         479,001,600          28,158,588,057,600
    15   1,307,674,368,000   3,497,296,636,753,920,000

24 left-deep orders for S JOIN A JOIN J JOIN C; rows built by each plan:
  J -> C -> A -> S            10,040
  C -> J -> A -> S            10,040
  A -> J -> C -> S            50,000
  C -> A -> J -> S        24,010,000
  A -> C -> S -> J        30,002,000
  C -> A -> S -> J        30,002,000
the worst builds 2,988 times as many rows as the best

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 join_orders.py

The best orders join the 60 companies to the 200 jobs first, keeping 40 rows, then the 8,000 applications to those jobs, then the 2,000 from the 2027 batch. The worst orders begin by pairing every application with every company, a cross product of 24 million rows, and build 2,988 times as many rows. Every order returns the same answer; cost-based optimisation is the choice between them.

The first table counts the candidates. A left-deep plan adds one table at a time, so there are n! of them. A bushy plan may also join two intermediate results, and counting every binary tree with its tables in order gives (2n − 2)! ÷ (n − 1)!: at 10 tables, 3.6 million left-deep orders and 17.6 billion trees. Join ordering by trying each one stops being possible after a handful of tables.

Dynamic programming over subsets, in brief

Selinger’s method rests on one observation. With a cost such as C_out, the rows of a join depend only on which tables it contains, not on the order inside it, so the cheapest way to join a set of tables is also part of the cheapest way to join any larger set that extends it. The optimiser therefore computes the best plan for every pair of tables, then for every triple built from the best pairs, and so on, keeping one plan per subset. The System R paper adds one refinement: a plan whose output is sorted in a way a later step can use is kept as well.

Three tables, their three pairs and the triple: the best plan joins applications to jobs first, then students, at a cost of 50,000.S: students12,500 rowsA: applications400,000 rowsJ: jobs200 rows{S, A}100,000 rowscost 100,000{A, J}40,000 rowscost 40,000{S, J}cross product2,500,000 rows{S, A, J}: 10,000 rowsbest = {A, J}, then Scost 50,00040,000 + 10,000= 50,000 (kept)100,000 + 10,000= 110,0002,500,000 + 10,000

Dynamic programming over subsets for students, applications and jobs

Text description of the diagram

The diagram is a lattice from top to bottom.

1. The top row holds the three tables after their filters: S, students, with 12,500 rows; A, applications, with 400,000; and J, jobs, with 200. 2. The middle row holds the three pairs. Students joined with applications give 100,000 rows. Applications joined with jobs give 40,000 rows. Students and jobs have no join condition, so they form a cross product of 2,500,000 rows. 3. The bottom node is the set of all three tables, whose join has 10,000 rows. It can be built from each pair by adding the third table: from applications and jobs the cost is 40,000 + 10,000 = 50,000, from students and applications 100,000 + 10,000 = 110,000, and from the cross product 2,500,000 + 10,000. The first is kept.

For three tables the method compares three ways to build the full set, each extending the best plan of a pair. For n tables it compares about n × 2ⁿ⁻¹ candidates instead of n! orders: 5,110 instead of 3,628,800 at 10 tables. Around it, real optimisers add a few named refinements:

  • Interesting orders keep a costlier plan of a subset when its sort order saves a later sort.
  • Greedy search builds one order step by step, always adding the table that looks cheapest next.
  • GEQO, PostgreSQL’s genetic query optimizer, recombines random join orders once a query has 12 or more FROM items.
  • join_collapse_limit and from_collapse_limit cap how many tables PostgreSQL reorders together.
  • Generic and custom plans are a prepared statement planned without or with its parameter values.

SQLite reorders joins; CROSS JOIN fixes the order

SQLite plans every join as nested loops and chooses the order of the loops itself. Its documentation says inner joins may be reordered freely by the optimiser, with one exception it keeps on purpose: it never reorders the tables of a CROSS JOIN. The same portal, with 40,000 applications and statistics from ANALYZE:

Two FROM orders, one plan; CROSS JOIN keeps the written order SQL · join_order.sql
-- A placement portal: 300 companies, 2,000 jobs, 5,000 students and 40,000 applications,
-- spread over the tables by multiplicative hashing so that every run builds the same data.
CREATE TABLE companies (id INTEGER PRIMARY KEY, city TEXT NOT NULL);
CREATE TABLE jobs (id INTEGER PRIMARY KEY, company_id INTEGER NOT NULL, role TEXT NOT NULL);
CREATE TABLE students (id INTEGER PRIMARY KEY, batch INTEGER NOT NULL);
CREATE TABLE applications (student_id INTEGER NOT NULL, job_id INTEGER NOT NULL, PRIMARY KEY (student_id, job_id));
CREATE INDEX companies_city ON companies (city);
CREATE INDEX jobs_company ON jobs (company_id);
CREATE INDEX applications_job ON applications (job_id);
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 300)
INSERT INTO companies SELECT i, CASE i % 5 WHEN 0 THEN 'Bengaluru' WHEN 1 THEN 'Pune' WHEN 2 THEN 'Chennai' WHEN 3 THEN 'Hyderabad' ELSE 'Noida' END FROM n;
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 2000)
INSERT INTO jobs SELECT i, 1 + (i * 2654435761) % 4294967296 % 300,
  CASE WHEN (i * 40503) % 65536 % 10 = 0 THEN 'SDE' ELSE 'other' END FROM n;
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 5000)
INSERT INTO students SELECT i, 2024 + (i * 69069) % 4294967296 % 4 FROM n;
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 40000)
INSERT OR IGNORE INTO applications
SELECT 1 + (i * 2654435761) % 4294967296 % 5000, 1 + ((i + 17) * 40503) % 65521 % 2000 FROM n;
ANALYZE;

-- 2027 students' applications to SDE jobs at Bengaluru companies.
SELECT count(*) AS applications
FROM students AS s JOIN applications AS a ON a.student_id = s.id
JOIN jobs AS j ON j.id = a.job_id JOIN companies AS c ON c.id = j.company_id
WHERE s.batch = 2027 AND j.role = 'SDE' AND c.city = 'Bengaluru';

-- The plan for the query as written ...
EXPLAIN QUERY PLAN
SELECT count(*) FROM students AS s JOIN applications AS a ON a.student_id = s.id
JOIN jobs AS j ON j.id = a.job_id JOIN companies AS c ON c.id = j.company_id
WHERE s.batch = 2027 AND j.role = 'SDE' AND c.city = 'Bengaluru';

-- ... and with the tables listed the other way round: the same plan.
EXPLAIN QUERY PLAN
SELECT count(*) FROM companies AS c JOIN jobs AS j ON c.id = j.company_id
JOIN applications AS a ON j.id = a.job_id JOIN students AS s ON a.student_id = s.id
WHERE s.batch = 2027 AND j.role = 'SDE' AND c.city = 'Bengaluru';

-- CROSS JOIN tells SQLite to keep the written order: students first.
EXPLAIN QUERY PLAN
SELECT count(*) FROM students AS s CROSS JOIN applications AS a CROSS JOIN jobs AS j CROSS JOIN companies AS c
WHERE a.student_id = s.id AND j.id = a.job_id AND c.id = j.company_id
  AND s.batch = 2027 AND j.role = 'SDE' AND c.city = 'Bengaluru';

Output

┌──────────────┐
│ applications │
├──────────────┤
│ 208          │
└──────────────┘
┌────┬────────┬─────────┬───────────────────────────────────────────────────────┐
│ id │ parent │ notused │                        detail                         │
├────┼────────┼─────────┼───────────────────────────────────────────────────────┤
│ 8  │ 0      │ 76      │ SEARCH c USING COVERING INDEX companies_city (city=?) │
│ 12 │ 0      │ 55      │ SEARCH j USING INDEX jobs_company (company_id=?)      │
│ 19 │ 0      │ 70      │ SEARCH a USING INDEX applications_job (job_id=?)      │
│ 25 │ 0      │ 0       │ BLOOM FILTER ON s (id=?)                              │
│ 33 │ 0      │ 39      │ SEARCH s USING INTEGER PRIMARY KEY (rowid=?)          │
└────┴────────┴─────────┴───────────────────────────────────────────────────────┘
┌────┬────────┬─────────┬───────────────────────────────────────────────────────┐
│ id │ parent │ notused │                        detail                         │
├────┼────────┼─────────┼───────────────────────────────────────────────────────┤
│ 8  │ 0      │ 76      │ SEARCH c USING COVERING INDEX companies_city (city=?) │
│ 12 │ 0      │ 55      │ SEARCH j USING INDEX jobs_company (company_id=?)      │
│ 19 │ 0      │ 70      │ SEARCH a USING INDEX applications_job (job_id=?)      │
│ 25 │ 0      │ 0       │ BLOOM FILTER ON s (id=?)                              │
│ 33 │ 0      │ 39      │ SEARCH s USING INTEGER PRIMARY KEY (rowid=?)          │
└────┴────────┴─────────┴───────────────────────────────────────────────────────┘
┌────┬────────┬─────────┬──────────────────────────────────────────────────────────────────────────────┐
│ id │ parent │ notused │                                    detail                                    │
├────┼────────┼─────────┼──────────────────────────────────────────────────────────────────────────────┤
│ 6  │ 0      │ 138     │ SCAN s                                                                       │
│ 10 │ 0      │ 53      │ SEARCH a USING COVERING INDEX sqlite_autoindex_applications_1 (student_id=?) │
│ 15 │ 0      │ 0       │ BLOOM FILTER ON j (id=?)                                                     │
│ 23 │ 0      │ 38      │ SEARCH j USING INTEGER PRIMARY KEY (rowid=?)                                 │
│ 31 │ 0      │ 0       │ BLOOM FILTER ON c (id=?)                                                     │
│ 39 │ 0      │ 35      │ SEARCH c USING INTEGER PRIMARY KEY (rowid=?)                                 │
└────┴────────┴─────────┴──────────────────────────────────────────────────────────────────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < join_order.sql

Both queries that use JOIN get the same plan, whatever their FROM order: start from the Bengaluru companies through the index on city, find their jobs, the applications to those jobs, then each applicant by key. With CROSS JOIN the written order wins, so SQLite scans all 5,000 students and looks up their applications, jobs and companies, filtering at every step. The answer, 208 applications, does not depend on the order; only the work does. SQLite’s documentation recommends this manual control only as a last resort, after ANALYZE and PRAGMA optimize.

Check yourself

Check yourself

4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 4 How many left-deep join orders does a join of 6 tables have?

    Type a number.

    Show the answer to question 1

    Answer: 720 orders

    A left-deep order is a sequence of the tables, so there are 6! = 720 of them. Counting every binary join tree with its tables in order gives (2 × 6 − 2)! ÷ (6 − 1)! = 30,240.

  2. Question 2 of 4 In the placement-portal query, the best order builds 10,040 rows and the worst about 30 million. What makes the worst orders so expensive?

    Choose one answer.

    Show the answer to question 2

    Answer: They start with a cross product, joining applications to companies without any condition between them

    Applications and companies share no join condition, so joining them first pairs every application with every Bengaluru company: 400,000 × 60 = 24 million rows. Every order that avoids such a product costs at most 112,000.

  3. Question 3 of 4 Why can dynamic programming keep just one plan for each subset of tables?

    Choose one answer.

    Show the answer to question 3

    Answer: Because the cost of joining more tables to a subset depends only on which tables it contains, not on the order inside it

    With a cost such as C_out, the rows of a subset's join are the same whatever the order inside it, so the cheapest way to build the subset is part of the cheapest way to build any larger set that extends it. Sort orders add one refinement: a plan that delivers a useful order may be kept as well.

  4. Question 4 of 4 What does writing CROSS JOIN instead of JOIN change in SQLite?

    Choose one answer.

    Show the answer to question 4

    Answer: The planner keeps the tables in the order they are written

    SQLite's documentation says it never reorders the tables of a CROSS JOIN, so the left table stays in the outer loop. The result is the same; only the plan changes, which makes CROSS JOIN a manual override that the documentation recommends only as a last resort.

Exercise

Exercise · Hard · Python

Find the cheapest left-deep join order with dynamic programming

Write best_order(rows, sel) in join_order.py. It returns the cheapest left-deep join order and its cost, as a pair (cost, order) where order is a tuple of table names.

rows gives each table's rows after its own filters, and sel gives the selectivity of the join condition between two tables, keyed by a frozenset of their names; two tables without a condition form a cross product. The file already has size(tables, rows, sel), the estimated rows of joining a set of tables, and cost(order, rows, sel), the C_out cost of an order: the rows of every intermediate result, the final one included.

Use dynamic programming over subsets: the best plan for a set of tables is the best plan for the set without one of its tables, followed by that table, for the choice that costs least. The sample tests include the lesson's tables, an order where starting from the smallest table is wrong, and a 10-table chain whose 3,628,800 orders are too many to try one by one within the time limit.

Starter code · join_order.py

"""Join ordering by dynamic programming over subsets of tables.

rows    table name -> its rows after its own filters, for example {"S": 12500, "A": 400000}
sel     frozenset({"S", "A"}) -> the selectivity of the join condition between two tables;
        two tables without a condition between them form a cross product (selectivity 1)

The cost of a left-deep order is C_out: the estimated rows of every intermediate result it builds, the final
result included and the base tables not counted.
"""
from itertools import combinations


def size(tables, rows, sel):
    """Estimated rows of the join of these tables."""
    n = 1.0
    for t in tables:
        n *= rows[t]
    for pair, s in sel.items():
        if pair <= set(tables):
            n *= s
    return n


def cost(order, rows, sel):
    """C_out of a left-deep order (a tuple of table names)."""
    return sum(size(order[:k], rows, sel) for k in range(2, len(order) + 1))


def best_order(rows, sel):
    """The cheapest left-deep order and its cost, as (cost, order)."""
    # Replace this line with your code.
    order = tuple(rows)
    return cost(order, rows, sel), order
The sample tests · test_join_order.py
from itertools import permutations

from join_order import best_order, cost


def brute_force(rows, sel):
    return min(cost(o, rows, sel) for o in permutations(rows))


def chain(sizes, selectivities):
    names = [chr(ord("A") + i) for i in range(len(sizes))]
    rows = dict(zip(names, sizes))
    sel = {frozenset(names[i:i + 2]): s for i, s in enumerate(selectivities)}
    return rows, sel


def test_three_tables():
    """the 3-table table of the lesson: start with the pair that joins to the fewest rows"""
    rows = {"S": 12_500, "A": 400_000, "J": 200}
    sel = {frozenset("SA"): 1 / 50_000, frozenset("AJ"): 1 / 2_000}
    c, order = best_order(rows, sel)
    assert abs(c - brute_force(rows, sel)) < 1e-6
    assert abs(cost(order, rows, sel) - c) < 1e-6
    assert set(order[:2]) == {"A", "J"}


def test_the_placement_portal():
    """four tables, with costs that range from 10,040 to 30 million"""
    rows = {"S": 12_500, "A": 400_000, "J": 200, "C": 60}
    sel = {frozenset("SA"): 1 / 50_000, frozenset("AJ"): 1 / 2_000, frozenset("JC"): 1 / 300}
    c, order = best_order(rows, sel)
    assert abs(c - 10_040) < 1e-6
    assert len(order) == 4 and set(order) == set(rows)


def test_the_smallest_table_is_not_always_first():
    """greedy from the smallest table costs 20,100 here; the best order costs 210"""
    rows, sel = chain([100, 1_000, 10_000, 100], [1 / 10, 1 / 10_000, 1 / 10_000])
    c, order = best_order(rows, sel)
    assert abs(c - 210) < 1e-6
    assert abs(cost(order, rows, sel) - 210) < 1e-6


def test_ten_tables_in_time():
    """3,628,800 orders are too many to try one by one in the time limit; subsets are not"""
    rows, sel = chain([500, 20_000, 300, 80_000, 1_000, 50_000, 40, 9_000, 700, 2_000],
                      [1 / 400, 1 / 20_000, 1 / 300, 1 / 80_000, 1 / 1_000, 1 / 50_000, 1 / 40, 1 / 9_000, 1 / 700])
    c, order = best_order(rows, sel)
    assert len(order) == 10 and set(order) == set(rows)
    assert abs(cost(order, rows, sel) - c) < 1e-6 * c
    assert abs(c - EXPECTED_TEN) < 1e-6 * EXPECTED_TEN


EXPECTED_TEN = 8_275
A hint

Keep a dict best from frozenset of tables to (cost, order), starting with every single table at cost 0. Then go through the subsets of size 2, 3 and so on (itertools.combinations): for each table t of a subset s, a candidate is best[s - {t}] extended by t, which costs best[s - {t}][0] + size(s, rows, sel). Keep the cheapest candidate in best[s].

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Selinger's dynamic programming, traced and measured against the alternatives Pro: opens with a Pro, Premium or Ultimate pass

The table of best plans for every subset is small enough to read in full for four tables. This part fills it in with a program, proves why one plan per subset is enough, and measures where greedy search and left-deep plans lose.

In this part:

  • The table of best plans for the portal, filled in by the program
  • Why one plan per subset is enough: the principle of optimality
  • Interesting orders, with numbers
  • Where greedy search and left-deep plans lose, measured

1 runnable example (Python) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

An exhaustive search has a price in planning time, and a plan reused across calls has a price when the data is skewed. This part shows the settings that bound PostgreSQL’s search and reproduces a cached plan that is right for most calls and wrong for the biggest one.

In this part:

  • join_collapse_limit, from_collapse_limit and explicit join order
  • GEQO from 12 FROM items
  • Skipping cross products: a bound that is usually right, with numbers
  • A cached plan for a skewed tenant, reproduced

1 runnable example (Python) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    How many left-deep join orders does a five-table join have, and why does no optimiser try every order of a 15-table join?

    A left-deep order is a sequence of the tables, so five tables have 5! = 120 of them; counting bushy trees as well gives 1,680. Fifteen tables have more than 1.3 trillion left-deep orders, and pricing each one would take far longer than running a merely good plan. Optimisers search instead. Dynamic programming over subsets keeps the best plan for every subset of tables and compares about n × 2ⁿ⁻¹ candidates, 245,745 at 15 tables; PostgreSQL switches to its genetic optimizer at 12 FROM items and considers joins without a join condition only when there is no other choice; SQLite keeps a handful of the best partial orders at each step.

  2. Mid to senior

    Explain how Selinger-style dynamic programming chooses a join order for four tables. Why is it enough to keep one plan per subset of tables, and when does it have to keep more than one?

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

  3. Mid to senior

    A prepared statement with WHERE tenant_id = $1 has been fast for weeks. After a deploy restarts the connection pool, queries for your largest tenant become slow while every other tenant stays fast. Explain what PostgreSQL's plan cache did, and give two fixes with their costs.

    Model answer, Pro. First step and follow-ups

  4. Senior to staff

    A reporting query joins 14 tables. Planning takes 300 ms, and the chosen join order changes between releases with no change to the query. What decides how PostgreSQL searches here, and what would you change?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Join orders differ by factors of thousands in the rows they build; the portal query ranges from 10,040 to 30 million.
  • n tables have n! left-deep orders and far more bushy plans, so optimisers search instead of trying them all.
  • Dynamic programming keeps the best plan of every subset of tables and needs about n × 2ⁿ⁻¹ comparisons.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

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