Chapter 04

Query Optimization & Join Methods

How one SQL statement becomes one execution plan, and how to price the alternatives. Relation profiles and selectivity, the four join methods and their cost formulas, external merge sort, and the discipline of writing a complete cost formula — the single largest points pool after concurrency.

Reading: ~48 min Interactive: 1 widgets Source: Polimi Data Base 2 2025/26 — Lecture 2 (Physical Databases), slides 66–148 · Polimi Data Base 2 2025/26 — Lecture 2b (Physical DB worksheet) · Polimi Data Base 2 2025/26 — guest material, Polimi information systems (EXPLAIN plan)

01 · Motivation

What the optimizer actually does

You wrote what you want. The optimizer decides how — and the same query can be executed in many ways whose costs differ by factors of thousands.

The query optimizer receives a query written in SQL and produces a program in an internal format that uses the data access methods. It runs in five phases:

  1. Lexical, syntactic and semantic analysis — is the query well formed, and do its tables and columns exist?
  2. Translation into an internal representation — something close to an algebraic tree.
  3. Algebraic optimization — rewriting the tree into an equivalent, cheaper one.
  4. Cost-based optimization — choosing access methods, join order and join algorithms using statistics.
  5. Code generation.

Phase 3 is the classical part and one example carries it. Given

SELECT LastName, FirstName
FROM Student, Exam
WHERE Student.ID = Exam.SID AND Exam.CourseID = 'DB2'

the naive tree computes the Cartesian product of Student and Exam, then filters. With 18 000 students each enrolled in about 10 exams, and only 200 taking DB2:

naive:      Student × Exam            = 18 000 × 180 000 = 3 240 000 000 rows
            then filter to               200
pushed:     filter Exam to CourseID='DB2'  →  200 rows
            then × Student            = 18 000 × 200     = 3 600 000 rows

Pushing the selection below the product shrinks the intermediate result by three orders of magnitude. The heuristic generalises to apply first the operations that reduce the size of intermediate results — and it is a heuristic, not a guarantee of optimality.

02 · Statistics

Profiles and selectivity

Every cost estimate in this course begins by turning the given statistics into derived ones. Getting this line right is most of the exercise.

The data dictionary stores a relation profile for each table:

  • the cardinality — number of tuples TT;
  • the size in bytes of each attribute AjA_j;
  • the number of distinct values of each attribute, written val(Aj)\mathrm{val}(A_j);
  • the minimum and maximum value of each attribute.

These are recomputed periodically by an explicit command (update statistics and friends), not maintained continuously. They enable two things: some queries can be answered from the dictionary alone (select count(*) from T, or a comparison against the recorded min/max), and — the important one — the optimizer can estimate the size of intermediate results.

The selectivity of a predicate is the probability that a row satisfies it. Under the uniform distribution assumption — which the course applies whenever no distribution data is given — an equality predicate on an attribute with NN distinct values has

Selectivity of an equality predicate

sel(A=k)  =  1val(A)\mathrm{sel}(A = k) \;=\; \frac{1}{\mathrm{val}(A)}

so the number of tuples surviving it is T/val(A)T / \mathrm{val}(A).

Q

Query plan costing — 8 of 14 sessions, 90 points

Physical-DB exercises appear in 8 of the 14 sessions in the bank, worth 10–12 points each — the largest points pool after concurrency control. Every one of them opens the same way: convert the given statistics into tuples per block, surviving tuples per predicate, and matches per join key. The published solutions always print that derivation as a block before any plan is priced, and the exercise text asks for “all the needed computations”. Do it explicitly; it is worth marks on its own and it is where an arithmetic slip becomes visible.

The derived quantities you will need, from a typical exercise:

Given:  Hotel   500 K tuples, 12 K blocks, val(Country)=100, val(Stars)=5
        Booking  20 M tuples, 200 K blocks, val(RoomType)=10, val(Channel)=4
                 3% of bookings are in July 2025

Derive: hotels in Italy with 4 stars   = 500 K / (100 × 5)  = 1 000
        bookings matching all three    = 20 M × 3% / (10×4) = 15 000
        bookings per hotel             = 20 M / 500 K       = 40

Three lines, and the rest of the exercise is arithmetic on them.

03 · Joins

The four join methods

Joins are the most frequent and most expensive operation a DBMS performs, so they get four distinct algorithms with sharply different cost profiles.

Throughout, bextb_{ext} and bintb_{int} are the block counts of the external (outer) and internal (inner) tables, textt_{ext} the tuple count of the external one.

1 · Nested loop

Scan the external table; for each of its blocks, scan the whole internal table. Always available, quadratic, and usually a last resort.


C=bext+bextbintbextbintC = b_{ext} + b_{ext} \cdot b_{int} \approx b_{ext} \cdot b_{int}

2 · Scan & lookup (indexed nested loop)

Scan the external table; for each tuple, use an index on the internal one to fetch only matching rows. Requires an index on the join attribute of the inner table.


C=bext+text(cost of one indexed access)C = b_{ext} + t_{ext} \cdot (\text{cost of one indexed access})

3 · Merge-scan

Both inputs sorted on the join attribute: walk them together, advancing whichever is behind. Linear — but only possible when both are ordered (or a B+ on the join key provides the order).


C=bL+bRC = b_L + b_R (plus sort cost if one must be sorted)

4 · Hash join

Both inputs hashed on the join attribute with the same function: matches can only be in corresponding buckets, so compare bucket by bucket.


C=bL+bRC = b_L + b_R

Two refinements to the nested loop matter in exercises. If one table is small enough to fit in the buffer, cache it and the cost collapses to bext+bintb_{ext} + b_{int} — the lectures’ example joins 1.7 K blocks of STUDENT with a 10-block CITIES table for ≈ 1.7 K I/O rather than 17 000. And note the asymmetry in scan & lookup: the multiplier is textt_{ext}, the number of tuples, not bextb_{ext}, the number of blocks. That single distinction is worth a factor of the block factor — often 100×.

×

Merge-scan and hash join need a precondition, not a preference

Both cost bL+bRb_L + b_R, which makes them look like free wins. They are not always available: merge-scan requires both inputs ordered on the join attribute (a sequentially-ordered primary structure, or a B+ tree on it), and hash join requires both hashed on the join attribute with the same hash function. If the exercise does not give you those structures, the method is off the table — say so rather than quietly using it.

04 · Joins

Picking the driving side

When both tables carry filters, the plan has a choice: which table to scan first and filter, and which to probe. The wrong choice can cost thirty times more.

The lectures set it up with STUDENT ⋈ EXAM filtered by City='Milan' AND Grade='30', given val(City)=150\mathrm{val}(City) = 150 and val(Grade)=17\mathrm{val}(Grade) = 17:

Option 1 — scan STUDENT, filter Milan, look up EXAM
  read 1.7 K blocks; 150 K / 150 = 1 000 Milan students survive
  for each, scan EXAM's 5.3 K blocks
  C = 1.7 K + 1 000 × 5.3 K  ≈  5.3 M I/O

Option 2 — scan EXAM, filter Grade=30, look up STUDENT
  read 5.3 K blocks; 1.8 M / 17 = 106 K exams survive
  for each, scan STUDENT's 1.7 K blocks
  C = 5.3 K + 106 K × 1.7 K  ≈  180 M I/O

Same query, same data, a factor of 34 between them — decided entirely by which filter is more selective. The rule: drive from the side whose predicate leaves fewer rows, because that count is the multiplier.

Now add a secondary hash index on EXAM(SID) and option 1 improves dramatically. Each of the 1 000 Milan students costs one hash access plus the 1.8 M / 150 K = 12 exams to fetch:

C = 1.7 K + 1 000 × (1 + 12) = 14.7 K I/O

and if the query only needs to know that a match exists rather than reading the exam rows, the 12 disappears and it falls to 2.7 K.

Q

Exercises hand you scenarios precisely to force this comparison

The physical-DB question is nearly always staged: “scenario 1, no auxiliary structures; scenario 2, add this index; scenario 3, add that one” — and it frequently asks for two plans within one scenario (“one starting from the City table and one starting from the Event table”, 2025-09-05). The marks are for the comparison, not for finding the single best plan. Price both directions and say which wins and why.

The September 2025 paper adds a twist worth knowing, because it is the only place in the bank where a join can be removed rather than reordered:

Deep dive Join elimination via a foreign key

The query joins City and Event on (CountryName, CityName) and filters both sides. Its final sub-question asks: does anything change if SELECT * becomes SELECT Event.*?

It does — but only under a condition. If Event declares a foreign key on (CountryName, CityName) referencing City, then every event’s pair is guaranteed to exist in City. The join can therefore never filter any event out; and if the projection no longer needs any City column, the join contributes nothing at all. A plan starting from Event may then terminate without ever touching City: the cost drops from 5 016 to 5 000 I/O in the no-index scenario, and from 10 030 to 10 014 with the date index.

Without the constraint, the optimizer cannot make that inference and all three plans must perform the join to verify matches. The lesson generalises: integrity constraints are optimizer inputs, not just correctness rules.

2025-09-05-q12025Q01Query plan cost estimationhard12 pts
City(CountryName, CityName, Population) has 10K tuples in a primary B+ tree on the composite key (CountryName, CityName) — 4 levels, 1.3K leaf nodes. Event(EventID, EventName, CityName, CountryName, Date, Category) has 1M tuples in 5K blocks of entry-sequenced storage. val(Category) = 25; 1% of events fall between 2023-12-01 and 2024-01-31; val(CountryName) = 100; cities uniform across countries. Query: ```sql SELECT * FROM City JOIN Event ON City.CityName = Event.CityName AND City.CountryName = Event.CountryName WHERE City.CountryName = 'Italy' AND Event.Date BETWEEN '2023-12-01' AND '2024-01-31' AND Event.Category = 'New Year Festival'; ``` 1. No auxiliary structures — cost two plans: (i) starting from City (3 pts), (ii) starting from Event (3 pts). 2. (4 pts) A secondary B+ on Event(Date) exists (3 levels, 1.2K leaf nodes); write a plan starting from this index. 3. (2 pts) Do the plans change if the SELECT list becomes `Event.*`? Explain. Ignore page caching and materialisation of intermediate results.

05 · Operators

External merge sort

Sorting data too large for memory — needed for ORDER BY, for GROUP BY, and to make merge-scan joins possible.

To sort NN blocks using BB buffer pages:

  • Pass 1 reads BB blocks at a time, sorts them in memory, and writes them back as a sorted chunk. That produces N/B\lceil N/B \rceil chunks.
  • Every later pass uses B1B-1 pages for input chunks and 1 for output, merging B1B-1 chunks into one. Each pass reduces the chunk count by a factor of B1B-1 and lengthens chunks by the same factor.

Repeat until one chunk remains:

External merge sort — P passes over N blocks with B buffers

P=1+logB1N/B,C=2NPP = 1 + \lceil \log_{B - 1} \lceil N/B \rceil \rceil, \qquad C = 2NP

The 2N2N is because every pass reads and writes every block. With B=5B = 5 and N=40N = 40: pass 1 makes 8 chunks of 5 pages, pass 2 merges 4 at a time into 2 chunks of 20, pass 3 merges those into one — 1+log48=31 + \lceil \log_4 8 \rceil = 3 passes, 2×40×3=2402 \times 40 \times 3 = 240 I/O.

The formula degrades gently: sorting a billion blocks with 129 buffers takes 5 passes.

06 · Optimization

Cost-based plan choice

With profiles and formulas in hand, the optimizer searches. Three decisions, combinatorially many combinations.

The decisions are: which data access operations to execute (scan versus index access), in what order (crucially, the join order), and with which implementation option for each operation (which join method). The search is organised as a decision tree — each node a choice, each leaf a complete execution plan — and each leaf is priced by

Total plan cost

Ctotal  =  cI/OnI/O  +  ccpuncpuC_{total} \;=\; c_{I / O} \cdot n_{I / O} \;+\; c_{cpu} \cdot n_{cpu}

though in this course nI/On_{I/O} is all that is counted. The tree is explored with operations-research techniques (branch and bound), and — the honest part — optimizers aim for a good solution in a very short time, not the optimal one. Exhaustive search over join orders is factorial; spending longer optimizing than executing would be self-defeating.

Two execution strategies follow:

  • Compile and store — the query is compiled once and executed many times (a prepared statement). The internal code is stored alongside the catalog versions it depends on; a relevant catalog change invalidates it and forces recompilation.
  • Compile and go — immediate execution, no storage, though the code may survive briefly in memory for reuse.
PREPARE my_query1 FROM 'SELECT * FROM students WHERE id = ?';
EXECUTE my_query1 USING 54;
EXECUTE my_query1 USING 2;
DEALLOCATE PREPARE my_query1;

07 · Practice

Buffer pool, EXPLAIN, and a real plan

A short look at the live system, both to make the model concrete and to see where it is idealised.

When a query executes, the DBMS loads the pages it needs into the buffer pool. A page already there is a cache hit; otherwise a cache miss forces a disk read. The pool holds data pages, index pages, and in some systems execution plans. When it fills, pages are evicted by LRU or a variant — least recently used first.

That is precisely the mechanism our cost model ignores when it assumes no caching. The assumption is deliberate and conservative: it gives an upper bound and keeps the arithmetic checkable. Exercises that want the other number ask for it explicitly.

Every major system exposes the chosen plan through EXPLAIN / EXPLAIN PLAN. Here is a genuine one, from Politecnico’s own student-records database — a five-way join returning seven rows:

| Id | Operation                          | Name                | Cost | A-Rows |
|  0 | SELECT STATEMENT                   |                     |   19 |      7 |
|  1 |  NESTED LOOPS                      |                     |   19 |      7 |
|  2 |   NESTED LOOPS                     |                     |   19 |      7 |
|  3 |    NESTED LOOPS                    |                     |   10 |      7 |
|  6 |     TABLE ACCESS BY INDEX ROWID    | AUNICA              |    3 |      1 |
|* 7 |      INDEX UNIQUE SCAN             | UK_AUNICA           |    2 |      1 |
|  8 |     TABLE ACCESS BY INDEX ROWID    | CARRIERA            |    4 |      1 |
|* 9 |      INDEX RANGE SCAN              | NU_CARR_ID_STUDENTE |    2 |      1 |

Read it inside-out and the vocabulary is exactly this chapter’s: nested loops, index unique scan for the equality on a key, index range scan for the non-unique foreign key, table access by rowid to fetch the tuple once the index gave its address. The optimizer drives from the most selective predicate — the single person id — and the whole query costs 19 units and about a millisecond.

08 · Exam

Writing a full cost formula

The exercise does not ask for a number. It asks for a plan description, the block counts with their derivations, and the complete formula — cached and uncached where both apply.

Here is the drill on a real paper, February 2026, worked end to end.

Worked exam Costing three scenarios (exam 2026-02-13)

The setup

Athlete — 40 K tuples, primary hash on AthleteId, 2 K blocks + 0.4 K overflow (lookup cost 1.2). Result — 120 K tuples, 6 K blocks, sequentially ordered by (Discipline, Score), with a B+ on that pair (3 levels, 600 leaves). val(CountryCode)=100, val(Discipline)=20, 70 % of scores ≥ 90. Query: join on AthleteId, filter Discipline='ski jumping' AND Score>=90 AND CountryCode='ITA'.

Derive the statistics first

Result blocks per discipline = 6 K / 20 = 300, of which 70 % qualify → 210 blocks. Qualifying tuples = 120 K / 20 × 0.70 = 4 200. Italian athletes = 40 K / 100 = 400. Results per athlete = 120 K / 40 K = 3.

Scenario 1 — start from the B+

Search the B+ for ('ski jumping', 90): 2 intermediate + 1 leaf. Because the primary structure is sorted on that pair, the qualifying tuples are contiguous — read 210 consecutive blocks, not 4 200 random ones. Then hash-probe Athlete per tuple at 1.2.


C=2+1+210+4200×1.2=5253C = 2 + 1 + 210 + 4200 \times 1.2 = 5\,253 I/O.

Scenario 2 — start from the Athlete hash

Scan the hash table (2.4 K), filter to 400 Italians. For each, find their ski-jumping results: results are not organised by athlete, so each lookup pays the B+ descent plus the same 210-block discipline range.


C=2400+400×(3+210)=87.6KC = 2400 + 400 \times (3 + 210) = 87.6\,\text{K} I/O — sixteen times worse.

Scenario 3 — hash join

Add a hash index on Result(AthleteId) with the same hash organisation. Read both structures bucket by bucket (2.4 K + 2 K); the athlete tuple is already in hand so the ITA test is free; only the 400 Italians chase their 3 result pointers each.


C=2400+2000+400×3=5.6KC = 2400 + 2000 + 400 \times 3 = 5.6\,\text{K} I/O.

The verdict

Scenario 1 wins at 5 253, narrowly ahead of the hash join at 5 600. The decisive asset is not an index at all — it is that Result is physically clustered on the predicate, turning 4 200 scattered accesses into a 210-block sequential read.

×

Three ways to lose marks on a plan you got right

Ignoring clustering. When the primary structure is sorted on the predicate’s attributes, matching tuples are consecutive — counting one I/O per tuple inflates the answer tenfold.


Multiplying by blocks instead of tuples. In scan & lookup the multiplier is textt_{ext}; using bextb_{ext} understates the cost by the block factor.


Giving only the number. The rubric asks for the formula and the derivation of every term. A bare total earns a fraction of the marks even when it is correct.

2026-02-13-q32026Q03Query plan cost estimationhard11 pts
Athlete(AthleteId, Firstname, Lastname, Age, CountryCode) — 40K tuples in a primary hash table on AthleteId, 2K blocks + 0.4K overflow blocks (chain length 0.2). Result(ResultId, Discipline, Event, Stage, AthleteId, Score, Ranking) — 120K tuples in 6K blocks, primary sequentially-ordered by (Discipline, Score), supported by a B+ index on (Discipline, Score) with 3 levels and 600 leaf nodes. val(CountryCode) = 100, val(Discipline) = 20, 70% of scores are ≥ 90. Query: ```sql SELECT * FROM Athlete JOIN Result ON Athlete.AthleteId = Result.AthleteId WHERE Discipline = 'ski jumping' AND Score >= 90 AND CountryCode = 'ITA'; ``` No caching. 1. (4 pts) Plan starting from the B+ tree. 2. (4 pts) Plan starting from the Athlete hash table. 3. (3 pts) Adding a hash index on Result(AthleteId) (no overflow, same hash organisation as Athlete, 2K blocks) — plan starting from a hash join.

A plan scans a 5 K-block table, filters it to 1 200 tuples, and for each uses a secondary B+ index (4 levels) on a second table to fetch about 8 matching rows. What is the cost?

Load-bearing ideas

  • Five phases, of which two matter here: algebraic optimization (push selections down, shrink intermediate results) and cost-based optimization (access paths, join order, join methods).
  • Start every answer with the derived statistics — tuples per block, tuples surviving each predicate (T/val(A)T/\mathrm{val}(A) under uniformity), matches per join key. The marks are partly there.
  • Four join formulas. Nested loop bextbintb_{ext} \cdot b_{int}; scan & lookup bext+textclookupb_{ext} + t_{ext} \cdot c_{lookup}; merge-scan and hash join both bL+bRb_L + b_R — but only when their preconditions (sorted / same hash) actually hold.
  • The driving side decides the order of magnitude. Drive from the more selective filter; its surviving count is the multiplier.
  • External sort: 1+logB1N/B1 + \lceil \log_{B-1} \lceil N/B \rceil \rceil passes, 2N2N per pass.
  • Physical clustering beats indexing. A primary structure sorted on the predicate turns per-tuple pointer-chasing into a contiguous block read.
  • More indexes ≠ faster. When the pointers to follow outnumber the table’s blocks, the scan wins; several exam scenarios exist purely to make you notice this.
  • Exam radar. Re-derive before the exam: the four join cost formulas with their preconditions; the both-directions comparison for a filtered join; and the full worked shape of a three-scenario costing, including the cached variant when the exercise asks for it.