Chapter 03

Physical Storage & Access Structures

How tables actually sit on disk, and what each layout costs. The block-counting cost model, sequential and hash structures, the primary/secondary/clustering index taxonomy, B+ trees, composite indexes and the leftmost-prefix rule — the substrate every query-cost exercise is built on.

Reading: ~45 min Interactive: 3 widgets Source: Polimi Data Base 2 2025/26 — Lecture 2 (Physical Databases), slides 1–65 · Polimi Data Base 2 2025/26 — Lecture 2b (Physical DB worksheet) · Atzeni, Ceri, Fraternali, Paraboschi, Torlone — Basi di dati (2023)

01 · Foundation

Why we count blocks

Databases live on disk, and disk is slow in a way that dwarfs every other cost in the system. That single fact reduces performance analysis to counting block transfers.

Data must be in main memory to be used, so every access moves data between two levels:

  • a block is the unit of storage in secondary memory;
  • a page is the unit of storage in main memory.

Throughout this course we assume one page holds exactly one block — real systems may pack several blocks per page, but the assumption costs nothing and simplifies every formula. An I/O operation moves one block in either direction.

For a mechanical disk the time breaks down as:

ComponentTypicalWhat it is
Seek time8–12 msmoving the head to the right track
Latency time2–8 mswaiting for the right sector to rotate under it
Transfer time~1 msactually moving the data

Add them and a single block access costs roughly 10 ms, against roughly 1 µs for main memory: the cost of an access to secondary memory is about four orders of magnitude higher. In an I/O-bound application — which a DBMS overwhelmingly is — everything else disappears into the noise. Hence the definition this whole course runs on:

Query cost

cost of a query  =  #blocks moved to execute it\text{cost of a query} \;=\; \#\text{blocks moved to execute it}

No constants, no units, no CPU term. When chapter 4 asks you to “estimate the cost”, it is asking for that count.

why

Why the DBMS does not simply use the file system

The operating system’s file system manages secondary memory, and a DBMS uses only its crudest services — create/delete a file, read/write a block. Everything above that it does itself: how records are distributed across blocks, the internal structure of each block, and often the physical allocation of blocks on the disk. It has to, because those are exactly the decisions that determine the block count, and the OS knows nothing about your query.

02 · Foundation

Blocks, tuples, and the block factor

Blocks are the physical unit; tuples are the logical one. The ratio between them is the first number you compute in every exercise.

A block’s size is fixed by the file system and disk formatting. A tuple’s size depends on the schema and is often variable — nullable columns, varchar, and other non-fixed types see to that. Inside a block sit the useful data, a page dictionary of offsets to each item (data and dictionary grow as stacks from opposite ends), header and trailer for the access method and the file system, and a checksum.

The number that matters is the block factor — how many tuples fit in one block:

Block factor
B  =  SB/STB \;=\; \lfloor S_B / S_T \rfloor

with SBS_B the block size and STS_T the average tuple size. The leftover space is either wasted (unspanned records) or used by letting a tuple straddle two blocks (spanned, or hung-up, records).

A worked instance you will see again and again — it is the running example of the whole deck:

Block size          8 KB = 8192 bytes
STUDENT tuple       95 bytes  (4+25+25+25+12+3+1)
  block factor      8192 / 95   = 86.2  →  87 tuples/block (slides round up)
  150 000 tuples    150000 / 87 = 1.7 K blocks
EXAM tuple          17 bytes
  block factor      8192 / 17   = 482 tuples/block
  1 800 000 tuples              = 5.3 K blocks

Every table is stored in exactly one primary physical structure, which holds all its tuples, and may have any number of secondary structures — indexes, which hold only some field values interleaved with pointers to the primary structure’s blocks. Three families exist, and they are not equally suited to the two roles:

StructureAs primary storageAs secondary index
Sequentialtypicalnot used
Hash-basedused in some DBMSs (Oracle hash clusters, DB2)frequent
Tree-basedobsolete / raretypical

03 · Structures

Sequential structures

Tuples laid out one after another. Two variants, distinguished only by whether that order means anything.

Entry-sequenced (heap)

Order is the order of insertion. Excellent for insertion (no shifting), for space occupancy (all blocks and all space within them are used), and for full scans — select * from T. Poor for finding specific tuples, which may require scanning the whole file.

Sequentially-ordered

Tuples sorted by a key field. Excellent for range queries and for order by / group by that exploit the key. The problem is insertion and any update that grows a tuple, since both may force reordering.

Three standard techniques keep an ordered structure from having to reorder globally: differential files with periodic merging (the paper Yellow Pages model), free space left in each block at load time so reordering stays local, and an overflow file whose blocks are chained to the ones that filled up — a principle that recurs in hash structures.

OperationEntry-sequencedSequentially-ordered
INSERTefficientnot efficient
UPDATEefficient (delete + reinsert if it grows)not efficient if it grows
DELETEmark invalidmark invalid
SELECT … WHERE key = …not efficient (full scan)more efficient

The practical conclusion from the lectures is worth internalising: entry-sequenced is the most common primary organization — but paired with secondary access structures. The heap holds the data; indexes make it findable.

04 · Structures

Hash structures and overflow chains

Associative access: given a key value, compute where it lives. Constant cost for equality — and no help whatsoever for ranges.

A hash structure has NBN_B buckets, each typically one block. A hash function maps a key to a value in [0,NB1][0, N_B - 1], in two stages: folding turns the key (a string, say) into a positive integer spread over a large range, then hashing reduces it modulo the bucket count.

Efficient for tables that are small and near-static, and for point queries — equality on the key. Inefficient for range queries, full-table queries, and very dynamic content.

When a bucket fills, the surplus goes into an overflow chain, and lookups must follow it. The expected extra cost is a function of two quantities: the block factor BB, and the load factor

Load factor

load factor  =  TBNB\text{load factor} \;=\; \frac{T}{B \cdot N_B}

with TT the number of tuples. The lectures give the table from real access statistics — the average number of accesses to the overflow chain:

load ↓ / BB123510
0.50.5000.1770.0870.0310.005
0.60.7500.2930.1580.0660.015
0.71.1670.4940.2860.1360.042
0.82.0000.9030.5540.2890.110
0.94.4952.1461.3770.7770.345

Typical designs sit between 50 % and 80 % occupancy. At B=3B = 3 and 70 % load, a lookup costs 1+0.2861 + 0.286 I/O — one for the bucket, a fraction for the chain.

×

Hash never supports an interval — not even partially

Range predicates on a hash structure fall back to a full scan, because the hash function deliberately destroys order: keys 100 and 101 are as far apart as 100 and 7 000. Exercises exploit this constantly — an index whose first attribute is a range predicate is close to useless, which is exactly the trap in the June 2026 index-selection question.

You do not need to memorise the table. Exercises always give you the overflow cost — “with a cost of access due to overflow of 1.3”, “filling factor below 50 %, no overflow cost” — and you add it to the 1 for the bucket itself.

05 · Indexes

The index taxonomy

An index is a structure of [search key, block pointer] records — the analytic index at the back of a book, in database form.

Two orthogonal distinctions produce the vocabulary. First, density:

  • A dense index has an entry for every search-key value in the file. Only a search of the index plus one access to the tuple is needed. It works on entry-sequenced data.
  • A sparse index has entries for only some values — typically one per block. It takes less space but is slower, and it requires the data to be sequentially ordered on the search key, because reaching the right block is only useful if scanning forward from it finds the tuple.

Second, the relationship between the search key (SK) and the ordering key (OK) of the primary structure:

Index typeUnderlying structureSearch keyDensityHow many
Primarysequentially ordered, SK = OKuniquedense or sparseone/table
Secondaryentry-sequenced, or ordered SK ≠ OKunique or notnecessarily densemany/table
Clusteringsequentially ordered, SK = OKnon-uniquetypically sparseone/table

A secondary index must be dense because tuples with adjacent key values may sit in entirely different blocks — there is nothing to scan forward through. A clustering index generalises the primary index to a non-unique ordering key: the pointer for value X leads to the block holding the first tuple with that key, and the rest follow contiguously.

×

A search key is not a primary key

They are different concepts that share a word. A primary key is a minimal, unique, non-null set of attributes identifying a tuple — a logical constraint, implying nothing about access paths. A search key is the set of attributes an index is built on — aphysical access path, which may be unique or not. SQL’s PRIMARY KEY declares the constraint, and most systems happen to implement it with a unique index; that is a convenience, not the definition. The lectures also warn that the terminology for “primary/secondary/clustering” varies by vendor — check what a given system means.

Indexes are not free. They are smaller than the primary structure and often fit in memory, and they turn scans into lookups — but every insert, update and delete must also maintain every index. The lectures’ selection guidelines follow directly: do not index small tables; do index a primary key not already the key of the primary organization, heavily-used secondary keys, and frequently-accessed foreign keys; do index columns used in selections, joins, ORDER BY, GROUP BY, UNION, DISTINCT; but avoid indexing frequently-updated columns, columns a query will read a large proportion of anyway, and long character strings.

06 · Indexes

B+ trees

The structure SQL indexes are actually built from. Balanced — every root-to-leaf path has the same length — and shaped so that one node is one block.

A B+ tree is a multi-level index: a root, several intermediate levels, and the leaves. Its defining property is that all key values live in the leaves; internal nodes exist only to route.

  • Each node holds up to N1N-1 search keys and NN pointers, sized so the node fills one block. The fan-out therefore depends on block size, key size and pointer size — and is large, so most blocks in the tree are leaves.
  • A leaf holds at least (N1)/2\lceil (N-1)/2 \rceil keys; an internal node keeps at least N/2\lceil N/2 \rceil pointers, so every node is at least half full.
  • Keys within a node are sorted, and the leaves are chained left to right, which is what makes interval queries efficient.
  • The set of leaves forms a dense index — every existing key value appears.

Lookup for value VV descends from the root; at each internal node, take P1P_1 if V<K1V < K_1, take PNP_N if VKN1V \ge K_{N-1}, and otherwise take Pj+1P_{j+1} where KjV<Kj+1K_j \le V < K_{j+1}.

The costs — the reason we are here:

QueryCost
Equality, primary B+ (tuples in leaves)#levels
Equality, secondary B+#levels + 1 data block per pointer followed
Interval, primary#intermediate levels + #leaves spanned
Interval, secondary#intermediate levels + #leaves spanned + 1 per pointer
Index-only#intermediate levels + #leaves spanned — no data access at all

That last row is the one students miss and examiners reward. If every attribute the query needs is already in the index, the data blocks are never touched. The lectures’ own example: SELECT ID FROM Student WHERE ID BETWEEN 6 AND 19 costs 2 intermediate levels + 3 leaves = 5 I/O, against 8 for the same range when the full tuples are wanted.

Deep dive B trees, and why B+ won

A B tree eliminates the redundant storage of search-key values: each key appears exactly once, and an internal node carrying key KiK_i holds both a pointer to the sub-tree of keys between KiK_i and Ki+1K_{i+1} and a pointer to the block containing the tuple(s) with value KiK_i.

The trade-off is clean. A point lookup can be faster, because it may terminate at an internal node instead of descending to a leaf — the lectures’ example finds ID = 10 in 2 I/O rather than 4. Interval queries are worse, because there is no leaf chain to walk: the keys of a range are scattered across levels, so the traversal is a tree walk rather than a linear scan.

Since range predicates, ORDER BY, GROUP BY and merge-scan joins all want ordered traversal, relational systems overwhelmingly use B+ trees. When an exercise says “B+ tree with 3 levels and 1000 leaf nodes”, read it as: three block accesses to reach a leaf, and one thousand blocks’ worth of chained leaves to walk if the range is wide.

×

Non-unique keys need the sibling check

When the search key is not unique, the same value may span several leaves, and the search algorithm must be extended: once a leaf is reached, if the key is first in that node, the preceding sibling must also be visited (requiring a doubly-linked leaf chain), and the walk continues rightwards until a strictly greater key appears. Cost estimates must count every leaf holding the value, not one.

07 · Indexes

Composite indexes and the leftmost prefix

An index may be built on several attributes at once. Which queries it then serves is decided by one rule, and recent exams lean on it hard.

A B+ tree on (LastName, FirstName, City) orders entries lexicographically by that tuple. The consequence is the leftmost prefix rule: the index supports a predicate only if the predicates form a prefix of the index’s attribute list, with at most the last used attribute being a range.

Given B+ on (LastName, FirstName, City):

Query predicateSupported?
LastName='Rossi' AND FirstName='Paola' AND City='VE'fully — descend on all three
LastName='Bianchi' AND City='Milano'partly — descend on LastName, then filter
City='Milano'not at all — compare against a full scan

The third row is the trap. An index whose leading attribute is absent from the WHERE clause is not a cheap index — it is an irrelevant one, and the correct answer is to compare its cost against simply scanning the primary structure.

Q

Index selection — the rising physical-DB question

Composite indexes now dominate the physical exercises: the last three sessions to ask one (2025-01-15, 2025-06-04, 2026-06-12) all turn on prefix matching. June 2026 makes it the whole exercise — three candidate indexes over Booking, and you must estimate, for each, how many index blocks are read and how many candidate pointers must then be followed, before choosing. The index whose prefix matches the two equality predicates and ends on the range beats the one that leads with the range by a factor of thirty.

The estimator below is that exercise, made runnable: the three candidate indexes over Booking, the same WHERE clause, and the two numbers you owe for each — index blocks read and candidate pointers followed. The walk colours every attribute as the index reads it, so you can see why the date-first index reads a wide band of leaves yet follows few pointers, and the prefix-matched one wins on both. Reproduce all three, then open the real question.

Hands-on

Composite index estimator

The Booking query has three predicates. Three candidate indexes cover them in different orders — pick one and read off how many index blocks it reads and how many candidate pointers it then follows. The numbers are the ones the June-2026 solution prints, so you can mark your own working; toggle a predicate off to watch the leftmost-prefix rule bite.

RoomType
1/10
Channel
1/4
CheckInDate
3%

3 (descent) + 10,000 × 1/10 × 1/4 × 3% = 11

20,000,000 × 1/10 × 1/4 × 3% = 15,000 ≈ 15K

Index blocks
11
Candidate pointers
15,000
Best of the three?
Yes

Verdict · Every predicate this index stores lies in its navigable prefix — nothing is wasted, so it reads the fewest blocks and follows the fewest pointers.

indexindex blockscandidate pointerstotal
Idx1 · (CheckInDate, HotelID, Channel)303150,000150,303
Idx2 · (RoomType, Channel, CheckInDate) · best (shown)1115,00015,011
Idx3 · (Channel, RoomType, CustomerID)253500,000500,253
RoomTypenavigates the block rangeequality on a prefix attribute — narrows the leaf range
Channelnavigates the block rangeequality on a prefix attribute — narrows the leaf range
CheckInDatenavigates the block rangerange on the prefix — narrows the leaf range, then the prefix ends
Try thisLoad Idx1 (date first) and Idx2 (date last): both store the same three columns, yet Idx2 reads ~30× fewer blocks and 10× fewer pointers. Then turn CheckInDate in July off and watch Idx1 collapse — its only navigable attribute is gone.
TakeawayA composite index is only as good as the prefix the query can match. Equalities first, one range last, and nothing wasted after a gap — that ordering is what turns the same set of columns from a 500 000-pointer scan into a 15 000-pointer lookup.
2026-06-12-q32026Q03Query plan cost estimationhard11 pts
Hotel(HotelID, HotelName, City, Country, Stars) — 500K tuples, primary hash on HotelID, 12K blocks, average lookup 1.2 I/O. Booking(BookingID, HotelID, CustomerID, CheckInDate, Nights, RoomType, Channel) — 20M tuples, entry-sequenced, 200K blocks. val(Country) = 100, val(Stars) = 5, val(RoomType) = 10, val(Channel) = 4; 3% of bookings are in July 2025; all distributions uniform. No caching. Query: ```sql SELECT * FROM Hotel H JOIN Booking B ON H.HotelID = B.HotelID WHERE H.Country = 'Italy' AND H.Stars = 4 AND B.CheckInDate BETWEEN '2025-07-01' AND '2025-07-31' AND B.RoomType = 'Suite' AND B.Channel = 'Online'; ``` 1. (4 pts) No secondary structures — cost the plan starting from Hotel and the plan starting from Booking. 2. (4 pts) Three secondary B+ indexes exist on Booking, each with 4 levels and 10K leaf nodes: Idx1(CheckInDate, HotelID, Channel), Idx2(RoomType, Channel, CheckInDate), Idx3(Channel, RoomType, CustomerID). For each, estimate (a) index blocks accessed and (b) candidate pointers followed; choose the best index and write the full plan. 3. (3 pts) An additional secondary B+ on Booking(HotelID) (4 levels, 10K leaves) exists — cost the plan exploiting it.

08 · Costs

Lookup cost, structure by structure

This is the table the exam expects you to reproduce from understanding, not memory. Everything is “1 for the block you land on, plus whatever chasing you then have to do”.

For a primary structure — the one holding the actual tuples:

Primary structureGiven the key KFull scan
Sequential, entry-seq.cannot exploit K — scan#blocks
Sequential, ordered by Kscan exploiting K, stop early#blocks
Hash on K1 + overflow#blocks (K unusable)
B+ on K, unique#levels#intermediate + #blocks of tree
B+ on K, non-unique#intermediate levels + #consecutive blocks holding Kas above

For a secondary structure — pairs of (K, pointer) over any primary structure:

Secondary indexGiven the key K
Hash, K unique1 + overflow, then follow one pointer
Hash, K non-unique1 + overflow, then follow all pointers
B+, K unique#levels, then follow one pointer
B+, K non-unique#intermediate levels + #leaves holding K, then follow all pointers

“Follow a pointer” means load that block into memory — one I/O each.

×

The no-caching assumption

The slides state it explicitly and every solution applies it:

we assume that we reload a block for tuple Ti even if we already loaded it for tuple Tj

. So following 3 600 pointers costs 3 600 I/O even when the target table has only 5 000 blocks. That is what makes the June-2024 date lookup cost ≈ 3.6 K and what makes plenty of “obviously indexed” plans lose to a plain scan. When an exercise wants caching considered, it says so — and then usually asks for both formulas.

A worked pair from the running example, to see the shapes:

SELECT * FROM STUDENT WHERE LastName = 'Rossi'      -- secondary B+ on LastName
  val(LastName) = 75 K over 150 K tuples            → 2 tuples per surname
  4 levels (3 intermediate + 1 leaf), 2 tuples fit in 1 leaf block
  cost = 3 + 1 + 2 = 6 I/O

SELECT * FROM EXAM WHERE Date = '10/6/2019'         -- secondary B+ on Date
  val(Date) = 500 over 1.8 M tuples                 → 3 600 tuples per date
  1.8 M pointers over 2 K leaves                    → 900 pointers/leaf → 4 leaves
  cost = 3 + 4 + 3 600 ≈ 3.6 K I/O                  ← the pointers dominate

Same structure, same tree height — and a thousandfold difference in cost, entirely because of selectivity. That is the intuition chapter 4 formalises.

Both of those cases are loaded in the calculator below, along with the rest of the tables above. Run them, check the numbers against the worked pair you just read, then start changing one thing at a time: ask a hash structure for a range and watch the plan collapse to a scan, or push val(A) down on the LastName index until the pointers overtake the table’s own block count. The formula it prints is the one an exam wants written out — every term named, every number justified.

Hands-on

Lookup cost calculator

Pick an access path and a query and read off the complete cost formula, the numbers behind every term, and whether the path actually beats a plain scan. The examples are the ones worked in the lectures, so you can check your own answer against them.

STUDENT hashed on ID, 0.3 overflow chain. One bucket read and you are done — this is the cheapest lookup in the course.

bucket + overflow

= 1 + 0.3 = 1.3

Access cost
1.3
Scan cost
2,500
Better plan
This path

Verdict · 1.3 I/O against 2,500 for a full scan, so the access path is worth using.

matching tuples1a unique key matches exactly one tuple
entries per leaf75tuples per leaf block
leaves touched1matches / entries per leaf, rounded up
bucket1h(K) computes the bucket address directly — one block, no search.
overflow0.3Average chain length 0.3 — the fraction of lookups that spill past the first block.
Try thisLoad Index on LastName and drop val(A) from 75 000 to 500. Watch the pointer term take over the formula, then keep going until the scan wins — the crossover is the whole judgement an exam is testing.
TakeawayA cost is never a single remembered number: it is the descent, plus the leaves the matches span, plus one I/O per pointer followed. Tree height barely moves it — selectivity is what decides whether the index is worth using at all.

A table of 500 000 tuples occupies 20 000 blocks. A secondary B+ index on a non-unique column has 4 levels; the queried value matches 40 000 tuples. Roughly what does the indexed lookup cost, and is it a good plan?

Finally, two rules for combining predicates. In a conjunction, the DBMS picks the most selective supported predicate for the access and evaluates the rest in memory on the retrieved tuples. In a disjunction, indexes help only if every predicate is supported — then results are unioned and duplicates removed; if even one is unsupported, the whole query falls back to a full scan.

Load-bearing ideas

  • Cost = blocks moved. Secondary access is ~10⁴× main memory, so nothing else is counted.
  • Block factor B=SB/STB = \lfloor S_B / S_T \rfloor is the first number in every exercise; derive the block count from it.
  • One primary structure per table, any number of secondary indexes. Entry-sequenced primary plus indexes is the common design.
  • Hash gives equality in 1 + overflow and nothing else — no ranges, ever. Sequentially-ordered gives ranges; B+ gives both.
  • Primary / secondary / clustering is about SK versus OK, not importance: primary has SK = OK unique, clustering SK = OK non-unique, secondary SK ≠ OK and therefore necessarily dense.
  • B+ tree costs: equality = #levels; interval = #intermediate + #leaves spanned; add one block per pointer for a secondary index — and zero data accesses when the index alone answers the query.
  • Leftmost prefix. A composite index serves a predicate set only if it forms a prefix of the index’s attributes; a leading range attribute wastes the rest.
  • No caching. One I/O per pointer followed, even for repeated blocks — this is what makes many index plans lose to a scan.
  • Exam radar. Re-derive before the exam: the secondary-index cost with a non-unique key (levels
    • leaves + one per pointer), the index-only case, and the prefix table for composite indexes. Chapter 4 combines them into whole plans.