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.
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:
| Component | Typical | What it is |
|---|---|---|
| Seek time | 8–12 ms | moving the head to the right track |
| Latency time | 2–8 ms | waiting for the right sector to rotate under it |
| Transfer time | ~1 ms | actually 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:
No constants, no units, no CPU term. When chapter 4 asks you to “estimate the cost”, it is asking for that count.
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:
with the block size and 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 blocksEvery 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:
| Structure | As primary storage | As secondary index |
|---|---|---|
| Sequential | typical | not used |
| Hash-based | used in some DBMSs (Oracle hash clusters, DB2) | frequent |
| Tree-based | obsolete / rare | typical |
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.
| Operation | Entry-sequenced | Sequentially-ordered |
|---|---|---|
| INSERT | efficient | not efficient |
| UPDATE | efficient (delete + reinsert if it grows) | not efficient if it grows |
| DELETE | mark invalid | mark 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 buckets, each typically one block. A hash function maps a key to a value in , 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 , and the load factor
with the number of tuples. The lectures give the table from real access statistics — the average number of accesses to the overflow chain:
| load ↓ / → | 1 | 2 | 3 | 5 | 10 |
|---|---|---|---|---|---|
| 0.5 | 0.500 | 0.177 | 0.087 | 0.031 | 0.005 |
| 0.6 | 0.750 | 0.293 | 0.158 | 0.066 | 0.015 |
| 0.7 | 1.167 | 0.494 | 0.286 | 0.136 | 0.042 |
| 0.8 | 2.000 | 0.903 | 0.554 | 0.289 | 0.110 |
| 0.9 | 4.495 | 2.146 | 1.377 | 0.777 | 0.345 |
Typical designs sit between 50 % and 80 % occupancy. At and 70 % load, a lookup costs 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 type | Underlying structure | Search key | Density | How many |
|---|---|---|---|---|
| Primary | sequentially ordered, SK = OK | unique | dense or sparse | one/table |
| Secondary | entry-sequenced, or ordered SK ≠ OK | unique or not | necessarily dense | many/table |
| Clustering | sequentially ordered, SK = OK | non-unique | typically sparse | one/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 search keys and 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 keys; an internal node keeps at least 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 descends from the root; at each internal node, take if , take if , and otherwise take where .
The costs — the reason we are here:
| Query | Cost |
|---|---|
| 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 holds both a pointer to the sub-tree of keys between and and a pointer to the block containing the tuple(s) with value .
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 predicate | Supported? |
|---|---|
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.
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.
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.
3 (descent) + ⌈10,000 × 1/10 × 1/4 × 3%⌉ = 11
⌈20,000,000 × 1/10 × 1/4 × 3%⌉ = 15,000 ≈ 15K
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.
| index | index blocks | candidate pointers | total |
|---|---|---|---|
| Idx1 · (CheckInDate, HotelID, Channel) | 303 | 150,000 | 150,303 |
| Idx2 · (RoomType, Channel, CheckInDate) · best (shown) | 11 | 15,000 | 15,011 |
| Idx3 · (Channel, RoomType, CustomerID) | 253 | 500,000 | 500,253 |
| RoomType | navigates the block range | equality on a prefix attribute — narrows the leaf range |
| Channel | navigates the block range | equality on a prefix attribute — narrows the leaf range |
| CheckInDate | navigates the block range | range on the prefix — narrows the leaf range, then the prefix ends |
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 structure | Given the key K | Full scan |
|---|---|---|
| Sequential, entry-seq. | cannot exploit K — scan | #blocks |
| Sequential, ordered by K | scan exploiting K, stop early | #blocks |
| Hash on K | 1 + overflow | #blocks (K unusable) |
| B+ on K, unique | #levels | #intermediate + #blocks of tree |
| B+ on K, non-unique | #intermediate levels + #consecutive blocks holding K | as above |
For a secondary structure — pairs of (K, pointer) over any primary structure:
| Secondary index | Given the key K |
|---|---|
| Hash, K unique | 1 + overflow, then follow one pointer |
| Hash, K non-unique | 1 + 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 dominateSame 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.
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
Verdict · 1.3 I/O against 2,500 for a full scan, so the access path is worth using.
| matching tuples | 1 | a unique key matches exactly one tuple |
| entries per leaf | 75 | tuples per leaf block |
| leaves touched | 1 | matches / entries per leaf, rounded up |
| bucket | 1 | h(K) computes the bucket address directly — one block, no search. |
| overflow | 0.3 | Average chain length 0.3 — the fraction of lookups that spill past the first block. |
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 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.