Break
nanoDB: storage, algorithms, indexing, optimization
Nano quiz ↗
press b or esc to carry on
M3

Storage Layout: Row vs Columnar

A storage layout specifies how a table's bytes appear on disk.

3 figures
storage-layout · Inside a 64 MB Page: The Storage Layout Question

The same logical Listens table stored two ways on disk: row-oriented packs whole rows together into pages; columnar puts each column in its own file

The same logical Listens table stored two ways on disk: row-oriented packs whole rows together into pages; columnar puts each column in its own file The same logical Listens table stored two ways on disk: row-oriented packs whole rows together into pages; columnar puts each column in its own file. Both layouts live on disk, shown by green frames. The rating column is the highlight, shown in violet: a query for AVG(rating) reads only that one file in columnar, but every row page in row-oriented. Color key: green is the on-disk file layouts, violet is the focal rating column. Same logical table, two physical storage layouts Listens (logical table) what your query sees user_id song_id rating listen_time 42 100 5 10:32 7 200 4 10:35 13 100 3 10:40 42 300 5 10:42 7 100 5 10:45 stored as rows stored as columns Row-Oriented File · disk each page packs whole rows together Page 1 [ 42 · 100 · 5 · 10:32 ] [ 7 · 200 · 4 · 10:35 ] [ 13 · 100 · 3 · 10:40 ] Page 2 [ 42 · 300 · 5 · 10:42 ] [ 7 · 100 · 5 · 10:45 ] Columnar Files · disk each column gets its own file user_id 42 7 13 42 7 song_id 100 200 100 300 100 rating 5 4 3 5 5 AVG reads only this listen_time 10:32 10:35 10:40 10:42 10:45 green = both layouts on disk  ·  violet = the focal rating column. Columnar reads only it for AVG(rating); row-oriented reads every page.
The physical layout decides which bytes a query reads. The same logical Listens table appears in two of them. Row-oriented storage packs whole rows into a page, so reading row 5 returns all four columns in one read and a write is one append. Columnar storage gives each column its own file, so AVG(rating) touches only the rating file and skips the other three. Which layout wins depends on the query: SELECT AVG(rating) favors columnar (row layout wastes about 75% of the I/O on this 4-column toy, far more on wide tables), while SELECT * WHERE listen_id = 42 and inserts or updates favor row, since one record is one packed page rather than a seek-and-reassemble across four files.
Notes ↗
storage-layout · Row-Oriented: The Slotted Page

Slotted page layout: a single 64MB row-oriented page

Slotted page layout: a single 64MB row-oriented page Slotted page layout: a single 64MB row-oriented page on disk. Data records grow from the top down (Row A, B, C). The slot array grows from the bottom up; three slot cells (S0, S1, S2) point to their rows by label. Free space sits in the middle and shrinks as both grow toward each other. Color key: green is the page on disk (persistent); the rows and slots inside are neutral. Slotted Page (Row-Oriented Layout) on disk page header (metadata) Row A [42, 7, 100, 5, 10:32] Row B [7, 13, 200, 4, 10:35, 'longer comment field…'] Row C [13, 42, 300, 3, 10:40] data ↓ free space slot array (each slot = offset + length, fixed size) S0 → Row A S1 → Row B S2 → Row C + next → slots ↑ green = the page on disk  ·  slots grow up, rows grow down, free space shrinks between them.
A 64 MB slotted page stores variable-length records from the top downward and stores a fixed-size slot array from the bottom upward. Free space remains between them. Each slot stores an (offset, length) pointer, so S0 can point to Row A and S1 can point to Row B. An in-place update writes the row without moving it when the row keeps the same size. When a row grows, the page moves the row to a new location and updates the slot, so references like page_id, slot_id remain stable.
Notes ↗
storage-layout · Columnar Made Practical: Parquet

Parquet nests rows into row-groups, columns into column chunks, and bytes into ~1 MB pages, all in one file, for a sliver of compression in exchange for big gains in row access, file count, and write simplicity

Parquet nests rows into row-groups, columns into column chunks, and bytes into ~1 MB pages, all in one file, for a sliver of compression in exchange for big gains in row access, file count, and write simplicity Two layouts compared, revealed in steps. On the left, pure columnar (grey, out of focus) puts each column in its own file, so one row needs a seek per column and a wide table explodes into a hundred thousand files. On the right, one Parquet file (emerald, in focus) builds up in three beats: first the file as a sequence of row-groups of about 128 MB and a million rows, then the column chunks inside one row-group with the rating chunk highlighted, then a zoom showing a column chunk is a stack of roughly 1 MB pages. A note flags that Parquet's page is finer than the 64 MB DbFile page from earlier. A footer of per-row-group min/max statistics lets a query skip whole row-groups. The trade panel states the cost in amber and the gains in emerald. Color key: emerald is the Parquet file in focus, grey is pure columnar out of focus, amber is the cost. Parquet: columnar, packed into row-groups the practical middle: one file, columns kept local inside each row-group Pure columnar one file per column user_id.col song_id.col rating.col listen_time.col one row = a seek per column 100 cols × 1000 parts = 100,000 files listens.parquet · one file on disk nesting: row group ▸ column chunk ▸ ~1 MB page Row Group 1 · ~128 MB · ~1M rows Row Group 2 · ~128 MB ⋯ the file is a sequence of row-groups one column chunk per column user_id 42 · 7 · 13 dictionary song_id 100 · 200 · 100 dictionary rating 5 · 4 · 3 run-length listen_time 10:32 · 10:35 delta Inside the rating chunk: a stack of ~1 MB pages page · ~1 MB page · ~1 MB Parquet's page (~1 MB), not the 64 MB DbFile page from earlier Footer · schema + per-row-group min/max stats WHERE listen_time > T reads the footer first, then skips whole row-groups that cannot match. The trade Pay: compression resets at each row-group boundary, a sliver. Gain: a row stays local · one file, not 100,000 · the row-group is the unit of work for writes and parallel scans. emerald = the Parquet file in focus  ·  grey = pure columnar, out of focus  ·  amber = the cost
Parquet stores columnar data inside row-groups in one file. A pure columnar layout stores one file per column, so reading one row performs a seek per column and a wide partitioned table produces roughly 100,000 files. Parquet keeps per-column compression and scan behavior, and it stores the columns as chunks inside each row-group. A row stays within one row-group. Row-groups support writes and parallel scans. The footer stores per-row-group min/max statistics, and predicates can skip groups without reading their column chunks. Encodings stop at row-group boundaries, which reduces compression slightly.
Notes ↗
M3

Case Study 3.1: BigQuery, From Web Crawl to Petabyte Analytics

BigQuery scales to trillions of rows by storing data in a columnar format and decoupling storage from compute.

1 figure
case-study-bigquery-scaling

BigQuery: columnar storage scanned by thousands of on-demand workers on a tree

BigQuery: columnar storage scanned by thousands of on-demand workers on a tree BigQuery stores each column in its own file, so a query for average rating per genre reads only the rating and genre columns (green) and skips user_id, song_id, and listen_time (grey). Thousands of leaf workers, spun up only while the query runs, each scan a slice of those columns; their partial results bubble up a tree to a root that aggregates the answer. Color key: green is the columns read and the worker tree, grey is the skipped columns. BigQuery: read only the columns you touch, on thousands of workers Columnar storage skips untouched columns; a tree of on-demand workers scans the rest in parallel. user_idskipped song_idskipped ratingread genreread listen_timeskipped columnar:one fileper column worker worker worker worker worker × thousands,on demand Root: AVG(rating) per genre results bubble up the tree green = columns read + the worker tree  ·  grey = skipped columns
BigQuery answers a query over trillions of rows with columnar storage and tree execution. A query for average rating per genre reads the rating and genre column files (green) and skips user_id, song_id, and listen_time (grey). Thousands of leaf workers run while the query runs, each scans a slice of those columns, and partial results flow up a tree to a root that aggregates the answer. BigQuery decouples storage and compute, so what took hours on row storage finishes in seconds.
Notes ↗
M3

Hybrid Storage: One Table, Two Layouts

Hybrid storage keeps row-oriented and column-oriented layouts in the same table.

1 figure
hybrid-storage-example · How It Works

Hybrid storage: row on write, columnar on read, split by data age

Hybrid storage: row on write, columnar on read, split by data age A time axis runs from newest, hot data on the left to oldest, cold data on the right. On the left, fresh listens land as small grey row-shaped parts that take inserts at about a million per second. A grey background-merge arrow in the middle converts aged parts into the large green columnar segments on the right, which answer analytics scans in about 100 ms with roughly ten to one compression. A single green unified-query bar spans the bottom: one SQL surface reads both layouts and merges the results. Color key: green is the columnar result and the unified engine; grey is the row parts and the scaffolding. One table, two layouts, picked by data age Partition by listen_time. A background merge turns aged parts columnar. newest · hot (writes) oldest · cold (scans) Recent partitions row-shaped part row-shaped part row-shaped part Inserts: ~1M/sec whole row written in one place background merge when activity slows row ~10:1 Aged partitions columnar segment columnar segment Scans: ~100 ms · ~10:1 compression one column read, the rest skipped Unified query engine: one SQL surface reads both layouts and merges the result Color key green = columnar result + unified engine · grey = row parts and scaffolding
listen_time provides a partitioning dimension. The system writes fresh listens into small row-shaped parts (grey) to accept inserts at about 1M/sec by writing each row contiguously. A background merge rewrites older parts into columnar segments (green) after activity slows. Analytics scan one column and skip others at roughly 10:1 compression. One query engine reads both layouts through the same SQL surface, without a second system, without ETL lag, and without manual layout choice.
Notes ↗
M3

Hash Partitioning: Divide and Conquer

Hash partitioning splits a too-big-for-RAM table into N partitions by hashing a key.

1 figure
hash-partitioning · The Hash Partitioning Algorithm

Hash partitioning shown as DISK input on the left, a small RAM workspace in the middle, and DISK partitions on the right

Hash partitioning shown as DISK input on the left, a small RAM workspace in the middle, and DISK partitions on the right Hash partitioning shown as DISK input on the left, a small RAM workspace in the middle, and DISK partitions on the right. The disk columns are visibly large; RAM is intentionally small to convey that the input file does not fit in RAM. The source file is read one page at a time into a RAM input-buffer slot. Each row is hashed (h(user_id) % 3) and routed to one of three output buffers. When a buffer fills, it flushes to its partition file on disk. Color key: red is RAM (volatile and limited), green is disk (persistent), grey is out of focus. Hash Partitioning: split a too-big-for-RAM file into B partitions DISK · input P1 42·100 · 7·200 P2 13·100 · 42·300 P3 7·100 · 13·200 P4 42·400 · 99·500 P5 88·600 · 7·700 P6 13·800 · 42·900 + thousands more pages (won't fit in RAM) read RAM input buffer (1-page slot · swapped each iteration) e.g. P3: 7·100, 13·200 · each row hashed, then routed ↓ h(user_id) % 3 ↓ B = 3 output buffers (one per partition) B₀ 42, 99 (h=0), fills then flushes → B₁ 7, 88 (h=1), fills then flushes → B₂ 13 (h=2), fills then flushes → flush flush flush DISK · partitions P₀ (h=0) 42·100 · 42·300 42·400 · 99·500 42·900 · … P₁ (h=1) 7·200 · 7·100 7·700 · … P₂ (h=2) 13·100 · 13·200 13·800 · … Color key  red = RAM, volatile and limited  ·  green = disk, persistent
Hash partitioning makes one pass over a too-big-for-RAM file using a small RAM workspace. A page streams into the input slot, each row hashes by h(user_id) % B and routes to one of ~B output buffers, and a full buffer flushes to its partition file on disk. Rows with the same key always land in the same partition, so later stages can sort, join, or group one partition at a time. Cost is below; see also the IO Cost Summary.
Notes ↗
M3

BigSort: Sorting TBs of Data with GBs of RAM

BigSort uses external merge-sort.

1 figure
big-sort · How External Merge Sort Uses Memory

External merge sort as a memory model

External merge sort as a memory model Pages stream from disk through a small four-page RAM box and back. Phase one reads four pages into RAM, quicksorts them, and writes a sorted run; repeat to get several runs. Phase two reads the head page of each run into RAM and emits the smallest value over and over, merging the runs into one sorted file. Each tile is a page shaded by its sort key, light for small and dark for large, so a sorted run reads as a smooth light-to-dark gradient. Color key: the green box is disk (persistent), the red box is RAM (volatile), the neutral tiles are pages shaded by sort key. External Merge Sort: 4 Pages of RAM at a Time Read a few pages, sort or merge them in memory, write them back. Never hold more than RAM. PHASE 1 · SORT RUNS Disk · unsorted file read 4RAM · 4 pages quicksort in place write runDisk · sorted runs (each a gradient) PHASE 2 · MERGE Disk · sorted runsread each headRAM · merge buffers in buffersout · emit smallest writeDisk · one fully sorted run Legend light → dark = sort key (small → large)red = RAM (volatile)green = disk (persistent)
Phase 1 reads four pages into memory, quicksorts them in place, and writes the pages back as one sorted run, repeating until the file becomes a set of sorted runs. Phase 2 keeps the head page of each run in memory, repeatedly selects the smallest remaining value, and writes it to the output to merge the runs into one sorted file. Each tile is a page shaded by its sort key, light for small and dark for large. The red box is RAM (volatile). The green is disk (persistent).
Notes ↗
M3

Block Nested Loop Join: The Simple Double Loop

Block nested loop join loads a block of the outer table into RAM, scans the inner table once per block, and applies the join predicate to candidate row pairs.

1 figure
block-nested-loop-join · The Algorithm: Load Blocks, Scan Everything

Block Nested Loop Join shown as DISK on the left and RAM on the right

Block Nested Loop Join shown as DISK on the left and RAM on the right Block Nested Loop Join shown as DISK on the left and RAM on the right. R lives on disk as one page and is loaded into a RAM block of identical shape (one read). S lives on disk as four pages; one at a time, each is loaded into an S buffer slot in RAM and probed against the R block, producing joined rows (user_id, name, song_id). Color key: red strip is RAM (volatile and limited), green strip is disk (persistent). Block Nested Loop Join: load R into RAM once, stream S past page-by-page DISK R (1 page) 7·Mickey · 13·Minnie · 42·Pluto · 99·Daffy S (4 pages, read in order) P1 42·100 · 7·200 P2 13·100 · 42·300 P3 7·100 · 13·200 P4 42·400 · 99·500 load once stream (1 page at a time) RAM R block (loaded once · stays for the whole join) 7·Mickey · 13·Minnie · 42·Pluto · 99·Daffy S buffer (1-page slot · swapped each iteration) Join output · R ⋈ S on user_id P1 → (42, Pluto, 100) · (7, Mickey, 200) P2 → (13, Minnie, 100) · (42, Pluto, 300) P3 → (7, Mickey, 100) · (13, Minnie, 200) P4 → (42, Pluto, 400) · (99, Daffy, 500) = 8 joined rows Color key  red strip = RAM, volatile and limited  ·  green strip = disk, persistent
Block Nested Loop Join, R ⋈ S. Read the smaller table R from disk and hold it in RAM for the whole join. The larger table S streams past in 4 pages, each loaded into a one-page slot, probed against R, then discarded, producing 8 joined rows (2 per S page here). Best case, when R fits in B pages, is P(R) + P(S) + OUT; if R is bigger than B it becomes P(R) + ⌈P(R)/B⌉ × P(S) + OUT, because S is re-scanned once per R-block. Always make the smaller table the outer one.
Notes ↗
M3

Hash Partition Join: Joining Tables Bigger Than RAM

Hash partition join joins two tables that do not fit in RAM.

1 figure
hash-partition-join · Hash Partition Join: The Solution

Hash Partition Join: in Phase 1 both R and S are hashed on the join key into matching partitions on disk; in Phase 2 each partition pair is joined in RAM

Hash Partition Join: in Phase 1 both R and S are hashed on the join key into matching partitions on disk; in Phase 2 each partition pair is joined in RAM Hash Partition Join. Phase 1: both R and S are read from disk and hashed on the join key into matching partitions written back to disk, so the same key always lands in the same partition number. Phase 2: each partition pair is loaded into RAM and joined there. Color key: green is the tables and partitions on disk (persistent), red is the per-partition join in RAM (volatile); partitions are identified by their h to 0, 1, 2 labels. Hash Partition Join: R ⋈ S on user_id Phase 1 · read from disk, hash into partitions on disk R (Users) · disk user_id name 7 Mickey 13 Minnie 42 Pluto 99 Daffy h(user_id) R₀ · disk (h → 0) 42 · Pluto 99 · Daffy R₁ · disk (h → 1) 7 · Mickey R₂ · disk (h → 2) 13 · Minnie S (Listens) · disk user_id song_id 42 100 7 200 13 100 42 300 7 100 13 200 42 400 99 · 500 h(user_id) (same h) S₀ · disk (h → 0) 42 · 100 42 · 300 42 · 400 99 · 500 S₁ · disk (h → 1) 7 · 200 7 · 100 S₂ · disk (h → 2) 13 · 100 13 · 200 Phase 2 · load each partition pair into RAM and join there R₀ ⋈ S₀ · in RAM build hash on R₀, probe with S₀ → 4 output rows user_id 42 → 3 matches · user_id 99 → 1 match R₁ ⋈ S₁ · in RAM build hash on R₁, probe with S₁ → 2 output rows user_id 7 → 2 matches R₂ ⋈ S₂ · in RAM build hash on R₂, probe with S₂ → 2 output rows user_id 13 → 2 matches Color key  green = tables and partitions on disk  ·  red = per-partition join in RAM  ·  same key always lands in the same partition (h → 0, 1, 2)
Hash Partition Join, R ⋈ S on user_id. Phase 1 hashes both tables with the same function so matching user_ids always land in the same partition number. Phase 2 joins each (Rᵢ, Sᵢ) pair independently in RAM, building a hash table on R and probing with S, three parallelizable joins that yield 4 + 2 + 2 = 8 rows. Best-case cost is 3(N+M) + OUT (read each table, write partitions, read them back, write the output). The worst case is hash skew, where a hot key overflows a partition past RAM and recursive re-partitioning or a sort-merge fallback adds another pass.
Notes ↗
M3

Sort-Merge Join: When Order Matters

Sort-merge join sorts both tables on the join key, then scans them in lockstep and emits matching rows for equal keys.

1 figure
sort-merge-join · The Sort-Merge Join Algorithm

Sort-Merge Join: after Phase 1 BigSort, both R and S are sorted on disk on the join key

Sort-Merge Join: after Phase 1 BigSort, both R and S are sorted on disk on the join key Sort-Merge Join: after Phase 1 BigSort, both R and S are sorted on the join key and live on disk. Phase 2 walks both tables in one pass with two pointers in RAM, emitting matched groups as it goes. The dashed match lines connect each key's R row to its matching S rows: key 7 yields 2 outputs, key 13 yields 2, key 42 yields 3, key 99 yields 1. Total 8 output rows. Color key: green is the sorted tables on disk (persistent), red is the two-pointer merge in RAM (volatile). Sort-Merge Join: walk both sorted tables in one pass R sorted on user_id · disk user_id name 7 Mickey 13 Minnie 42 Pluto 99 Daffy S sorted on user_id · disk user_id song_id 7 100 7 200 13 100 13 200 42 100 42 300 42 400 99 500 match match match match i ▸ j ▸ Two-pointer merge in RAM advance the smaller pointer, emit on equal keys 8 output rows streamed to disk Color key  green = sorted tables on disk  ·  red = two-pointer merge in RAM  ·  dashed lines connect each key's matching rows
Sort-Merge Join, R ⋈ S on user_id (Phase 2 shown). Phase 1 (not shown) BigSorts both R and S on the join key (see the big sort page). Phase 2 scans the two sorted runs with two pointers. Equal keys form a matching key group, and the operator emits every pair in the group. Unequal keys advance the smaller side. The example advances through user_id 7 → 2, 13 → 2, 42 → 3, 99 → 1, total 8 rows. A key that appears on only one side emits nothing under inner join semantics. A key with m matches on one side and n on the other emits the m × n cross-product for that key. The output stream stays sorted on the join key, so a downstream operator that requires that order does not add a sort. Best case is P(R) + P(S) + OUT for one linear scan per run, plus the two Phase-1 BigSort costs. Worst case is O(P(R) × P(S)) + OUT when one key dominates and the merge performs a per-key cross-product. OUT can dominate when match rates are high.
Notes ↗
M3

IO Algorithms: Cost Analysis & Intuition

The IO cost of every algorithm in this module (BigSort, HashPartition, BNLJ, SMJ, HPJ) is a function of table size, buffer size, and the read/write cost ratio.

1 figure
io-algorithms · Real-World Examples: Choosing the Right Join Algorithm

Join IO cost versus buffer size, log scale

Join IO cost versus buffer size, log scale A log-scale line chart of IO cost against buffer size B for the same join, Songs3 100 GB by Listens3 1 TB, at C_r = C_w = 1. Buffer grows left to right: 10, 100, 1000, 10000 pages. BNLJ, the solid ink line, starts highest at 2.6M IOs and drops steeply to 18K as the buffer grows. SMJ-best, the dashed line, falls gently from 159K to 54K. HPJ-best, the dotted line, is flat at about 54K. A green crossover divider marks where the cheapest algorithm switches: HPJ is cheapest at small buffers, BNLJ at large buffers. Numbers are verified from the Module 3 NanoDB colab. Color key: green is the crossover and the winning regions; ink and grey are the cost lines and axes. Which join wins depends on the buffer, not the data Songs3 100 GB ⋈ Listens3 1 TB, C_r = C_w = 1, log scale. Verified from the M3 colab. 10K 100K 1M IO cost (log) B=10 B=100 B=1,000 B=10,000 buffer size B (pages) HPJ cheapest BNLJ cheapest BNLJ 2.6M BNLJ 18K SMJ-best 54K HPJ-best 54K (flat) Color key green = crossover and winning regions · solid BNLJ · dashed SMJ · dotted HPJ
HPJ-best stays near 54K because partitioning does one read and one write independent of B. BNLJ drops from 2.6M to 18K because larger B reduces rescans of the inner table. SMJ-best drops as larger initial runs reduce merge passes. The crossover marks the point where BNLJ becomes cheaper than HPJ at this scale. SMJ with many duplicates can rescan during merge. HPJ with heavy skew can create oversized partitions, each up to 2 to 10x worse.
Notes ↗
M3

Index Fundamentals

An index is an auxiliary data structure that maps a key (e.g.

1 figure
index-fundamentals · What an Index Stores

Table size vs index size: on the left the Songs table is a 10 TB file of 160,000 pages storing whole rows

Table size vs index size: on the left the Songs table is a 10 TB file of 160,000 pages storing whole rows Table size vs index size: on the left the Songs table is a 10 TB file of 160,000 pages storing whole rows. On the right the artist index is a 20 MB file of one page storing only (artist, page#) pointers. Size bars beneath show 10 TB vs 20 MB at the same horizontal scale. Both the table and the index live on disk and are shown green; the size difference is carried by the bar widths, not by colour. Color key: green is on-disk storage; the 10 TB table bar versus the 20 MB index sliver shows the roughly 500,000x size difference. Same data, two footprints rows on disk vs. (key, page#) pointers Songs table 10 TB · 160,000 pages 1 · Hey Jude · Beatles · 1968 2 · Bohemian Rhapsody · Queen · 1975 3 · Shake It Off · Taylor Swift · 2014 … 999,997 more rows (~10 MB each) holds the rows themselves 500,000× smaller rows → pointers Artist index 20 MB · 1 page Beatles → Page 47 Queen → Page 23 Taylor Swift → Page 12 … 999,997 more entries (~20 B each) holds (key, page#) pointers relative size on disk (same horizontal scale) 10 TB 20 MB (barely visible at this scale, which is the point) Color key  green = both on disk  ·  the 10 TB bar vs the 20 MB sliver shows the ~500,000× size difference
The Songs table stores whole 10 MB rows (10 TB, 160,000 pages). The artist index stores 20-byte pointers (20 MB, one page), 500,000 times smaller. WHERE artist = 'Beatles' without an index scans 160,000 pages. With an index, the database reads one index page, learns Beatles is on page 47, then reads page 47. The query uses two IOs instead of 160,000, 80,000 times fewer for the same answer.
Notes ↗
M3

Hash Indexes

A hash index computes hash(key) and uses it to choose the page that stores the key's index entry.

1 figure
hash-indexes · How a Hash Index Finds a Row

Hash index layout: on the right, three hash index pages hold (value, page#) entries, e.g. Alice points to Page 2

Hash index layout: on the right, three hash index pages hold (value, page#) entries, e.g. Alice points to Page 2 Hash index layout: on the right, three hash index pages hold (value, page#) entries, e.g. Alice to Page 2, Dan to Pages 0, 1, 4. On the left, five data pages hold the actual rows. Arrows show that a lookup for Alice jumps from an index entry to data page 2. Dan's entry fans out to three different data pages because duplicate keys land on multiple pages. Color key: green is the index and data pages on disk (persistent), violet is the lookup path. Hash Index: (value → page#) mapping hash(value) picks the index page · entry picks the data page Data pages hold the actual rows Page 0 Bob · Carol · Dan · Eve · Frank Page 1 Dan · Grace · Henry · Ian · Jack Page 2 Alice · Lisa · Mike · Nancy · Oscar Page 3 Paul · Quinn · Rose · Steve · Tom Page 4 Dan · Uma · Victor · Wendy · Xavier Hash index pages hold (value, page#) pointers Index page 0 Alice → Page 2 Bob → Page 0 Carol → Page 0 Index page 1 Dan → Pages 0, 1, 4 Eve → Page 0 Frank → Page 0 Index page 2 Grace → Page 1 Henry → Page 1 Ian → Page 1 … more index pages Alice → Page 2 Dan → three pages (duplicate keys fan out) Lookup(x): hash(x) → index page → find entry → read data page O(1) IOs regardless of table size Color key  green = index and data pages on disk, persistent  ·  violet = the lookup path
A hash index on People.name stores (value, page#) entries in buckets chosen by hash(value). A unique key like Alice requires 2 IOs: one index-page read and one data-page read. A duplicate key like Dan requires 4 IOs here: one index-page read plus reads of data pages 0, 1, and 4. The constant factor grows with the number of duplicate keys for the search value.
Notes ↗
M3

B+Tree Indexes

A B+Tree index stores keys in sorted order in a balanced tree.

2 figures
btree-indexes · What a B+Tree Stores

B+Tree layout on Songs.release_date_since_1970

B+Tree layout on Songs.release_date_since_1970 B+Tree layout on Songs.release_date_since_1970. Data pages on the left hold the rows. The tree on the right has a root node, three internal nodes, and a row of leaf nodes. Leaves store (key, data-page pointer) pairs in sorted order; neighbouring leaves are linked left-to-right so a range scan can walk across them without returning to the root. Color key: green is the index and data pages on disk (persistent), violet is the lookup trace for key 12890. B+Tree on Songs.release_date_since_1970 root · internal nodes · leaves sorted and linked for sequential range scans Data pages hold the rows themselves Page 0 1: Evermore (4821) 2: Willow (4953) 3: Shape of You (3187) Page 1 4: Photograph (3245) 5: Shivers (3367) 6: Yesterday (1523) Page 2 7: Yellow Submarine (1587) 8: Hey Jude (1612) 9: Bad Blood (1899) Page 3 10: Flowers (11234) 11: Unholy (11567) 12: As It Was (12890) 13: Heat Waves (13456) Page 4 15: Blinding Lights (13980) 16: Good 4 U (14456) 17: Drivers License (14789) B+Tree index Root 5000 15000 25000 < 5000 5000–14999 15000–24999 Internal 1900 3000 4000 Internal 11234 13789 14456 Internal 17000 20000 22000 Leaf nodes · (key, data-page pointer), sorted Leaf · < 1900 1523 → Pg 1 1587 → Pg 2 1612 → Pg 2 Leaf · 3000–3999 3187 → Pg 0 3245 → Pg 1 3367 → Pg 1 Leaf Leaf · 11234–13788 11234 → Pg 3 12890 → Pg 3 13456 → Pg 3 Leaf · 13789–14455 13789 → Pg 3 13980 → Pg 4 Leaf · ≥ 14456 14456 → Pg 4 14789 → Pg 4 ← leaves linked for sequential scans → leaf points to its data page Color key  green = index and data pages on disk, persistent  ·  violet = the lookup trace for key 12890
Data pages on disk store the Songs rows. The B+Tree on disk stores a root and internal nodes that route lookups to sorted leaves. Leaves store (key, data-page pointer) pairs and link left-to-right. For release_date = 12890 ("As It Was"), the root selects 5000..14999, the internal node selects 11234..13788, the leaf resolves 12890 → Page 3, and Page 3 contains the row. The lookup reads four pages.
Notes ↗
btree-indexes · Walking a Range Query

Walking a range query SELECT * WHERE release_date BETWEEN 12000 AND 14000 in a B+Tree

Walking a range query SELECT * WHERE release_date BETWEEN 12000 AND 14000 in a B+Tree Walking a range query SELECT * WHERE release_date BETWEEN 12000 AND 14000 in a B+Tree. Step 1 visits the root. Step 2 descends to the middle internal node (the other two internals are skipped). Step 3 walks from leaf 3 to leaf 4 sequentially, loading data pages 3 and 4. Total 7 IOs versus 160,000 for a full scan. Color key: green is the index and data pages on disk (persistent), violet is the range-walk path, grey dashed is skipped or not visited. Walking the range 12000 ≤ key ≤ 14000 one descent to the starting leaf · then stream across leaves until the end key Root · step 1 5000 15000 25000 12000 ≥ 5000, < 15000 Internal (< 5000) skipped Internal · step 2 11234 13789 14456 Internal (15000–24999) skipped Leaf (< 11234) not visited Leaf 3 · step 3 11234 → Pg 3 12890 → Pg 3 13456 → Pg 3 ≥ 12000 ✓ Leaf 4 · step 3 (cont.) 13789 → Pg 3 13980 → Pg 4 both ≤ 14000 ✓ Leaf 5 · peek 14456 → Pg 4 14789 → Pg 4 14456 > 14000, stop linked Data pages fetched Page 3 keys 12890, 13456 in range Page 4 key 13980 in range Cost 5 index IOs (root, internal, leaves 3 + 4, peek 5) 2 data-page IOs (pages 3 and 4) 7 IOs total · vs 160,000 full scan → ~23,000× faster Color key  green = index and data pages on disk, persistent  ·  violet = the range-walk path  ·  grey dashed = skipped
For 12000 ≤ release_date ≤ 14000, the query visits the solid green nodes, skips the dashed grey ones, and fetches only the data pages that contain matches (Page 3 and Page 4). The descent runs root → middle internal → Leaf 3, then follows leaf links. Leaf 3 yields 12890 and 13456. Leaf 4 yields 13789 and 13980. Leaf 4 ends at 13980, so the scan reads one more leaf to check the boundary. Leaf 5 begins with 14456, which exceeds 14000, so the scan stops. The internal node stores 14456 as the separator key for Leaf 5. The query reads 5 index pages and 2 data pages, 7 IOs total, versus roughly 160,000 for a full scan, about 23,000× faster.
Notes ↗
M3

SSTables: Sorted String Tables

An SSTable is an immutable file of key-value pairs sorted by key.

1 figure
sstables · Anatomy of an SSTable

SSTable anatomy: bloom filter + index block + data blocks all in one immutable file on disk; bloom filter is cached in RAM

SSTable anatomy: bloom filter + index block + data blocks all in one immutable file on disk; bloom filter is cached in RAM SSTable anatomy: bloom filter, index block, and data blocks all in one immutable file on disk; the bloom filter is also cached in RAM. Below: three SSTable files coexist on disk; the user_100 example in violet shows the newest version wins. Color key: green is the on-disk index and data blocks (persistent), red is the bloom filter held in RAM (the not-exists gatekeeper), violet follows user_100 across versions. SSTable anatomy one immutable file · many files coexist on disk 1 · one SSTable file, three sections Bloom filter held in RAM, the gatekeeper fast "not-exists" check ~1% false positive skips useless reads ~1 KB per million keys Index block on disk sparse (every Nth key) key100 → 4096 key200 → 8192 key300 → 12288 Data blocks · sorted by key on disk · compressed · read by offset key100 · v1 · {…} key101 · v1 · {…} key150 · v1 · {…} key200 · v1 · {…} 2 · many SSTables with overlapping keys; newest wins sstable_3.db newest · 1 hr ago [ user100:v3 · user150:v1 · user200:v2 · … · user400:v1 ] sstable_2.db 2 hr ago [ user050:v1 · user100:v2 · user200:v1 · … · user350:v1 ] sstable_1.db oldest · 3 hr ago [ user001:v1 · user100:v1 · user150:v1 · … · user600:v1 ] user100 (violet) appears in all three; the v3 in sstable_3 wins on read Color key  green = on-disk blocks  ·  red = bloom filter in RAM  ·  violet = user100 across versions, newest wins
An SSTable is one immutable on-disk file with three sections: a bloom filter in RAM that answers "is key K here?" in O(1) and can skip a disk read on a miss, a sparse index block that binary-searches to the target data block, and sorted, compressed data blocks. Multiple SSTables can contain the same key; a read returns the newest version, here user100's v3 in sstable_3.db over v1 and v2.
Notes ↗
M3

LSM Tree Indexes

An LSM tree buffers inserts in a RAM MemTable, flushes them to immutable SSTables, and merges SSTables in the background.

2 figures
lsm-indexes · Writes: How Data Moves from RAM to Disk

LSM write path at 1:44 PM

LSM write path at 1:44 PM LSM write path at 1:44 PM. Top lane: incoming plays land in the live MemTable in RAM. Middle lane: every 15 minutes the MemTable is flushed as a Level-0 SSTable on disk; the 1:00, 1:15, and 1:30 files are already there. Bottom lane: every hour compaction merges Level-0 files into one larger Level-1+ SSTable. Song S_75 has +2 plays in the MemTable, +18 across the Level-0 flushes, and 5,420 in the compacted history. Color key: red strip is the MemTable in RAM (volatile), green strip is SSTables on disk (persistent), violet follows song S_75 across the tiers. LSM write path · snapshot at 1:44 PM RAM on top · disk below · time flows downward LIVE (RAM) incoming plays MemTable (live at 1:44 PM) · RAM S12:1 · S44:2 · S75:+2 · S99:1 · S102:+1 · S201:5 … FLUSH every 15m flush Level-0 SSTables · one per flush · disk 1:30 PM.db … S55:1 S75:+10 S88:2 … 1:15 PM.db … S12:5 S102:+5 S111:1 … 1:00 PM.db … S22:4 S75:+8 S99:2 … COMPACT hourly compact · k-way merge Level-1+ SSTable · history, de-duplicated · disk merged_before_1:00_PM.db … S50:110 · S62:45 · S75 : 5,420 plays · S88:12 … S75's real total is smeared across all three tiers · merge-on-read sums them Color key  red strip = MemTable in RAM, volatile  ·  green strip = SSTables on disk, persistent  ·  violet = song S75 across tiers
LSM write path at 1:44 PM, RAM on top and disk below, time flowing downward. Incoming plays land in the in-RAM MemTable (fast, no seek, lost on crash unless replayed from the WAL). Every 15 minutes the engine flushes it to an immutable Level-0 SSTable on disk (1:00, 1:15, 1:30). Hourly compaction merges those into one larger sorted, de-duplicated Level-1+ file. Total plays for S75 spread across tiers (+2 in the MemTable, +10, +5, and +3 in Level-0, 5,420 in compacted history) and the read path sums them to 5,440 (merge-on-read). The merge operator is configurable: "add" for counters, "newest wins" for last-write-wins keys, "set union" for tags, "max" for high-water-marks. The merge section walks through more examples.
Notes ↗
lsm-indexes · Reads: Merge on Read

Merge-on-read for LSM: a user query for total plays of song S_75 gets +2 from the MemTable, +18 from the three Level-0 SSTables, and 5,420 from the compacted Level-1+ SSTable, summed to 5,440 total plays

Merge-on-read for LSM: a user query for total plays of song S_75 gets +2 from the MemTable, +18 from the three Level-0 SSTables, and 5,420 from the compacted Level-1+ SSTable, summed to 5,440 total plays Merge-on-read for LSM: a user query for total plays of song S_75 gets +2 from the MemTable, +18 from the three Level-0 SSTables, and 5,420 from the compacted Level-1+ SSTable, summed to 5,440 total plays. Color key: red strip is the MemTable in RAM (volatile), green strip is SSTables on disk (persistent), violet is the S_75 contributions and the summed answer. Merge-on-read · total plays for S75 the "state" isn't stored; it's computed by walking every tier Query GET plays WHERE song = 'S75' user doesn't know about tiers 1 · MemTable (live) · RAM one value from the live in-RAM buffer +2 2 · Level-0 SSTables (1:00 · 1:15 · 1:30) · disk sum matching entries across all three files +10 · +5 · +3 +18 3 · Level-1+ SSTable (compacted history) · disk single entry in the merged file +5,420 Σ result 5,440 total plays @ 1:44 PM State = merge( MemTable · Level 0 · Level 1+ ) Color key  red strip = MemTable in RAM, volatile  ·  green strip = SSTables on disk, persistent  ·  violet = S75 contributions, summed
Merge-on-read computes S75's play count at read time instead of storing it, by summing every tier that holds a fragment. The live in-RAM MemTable (red) adds +2, the three flushed Level-0 SSTables on disk (green) add +10 · +5 · +3 = +18, and the compacted Level-1+ SSTable on disk (green) adds +5,420, for a total of 2 + 18 + 5,420 = 5,440 (the merge function is "add" because plays are a counter). Each SSTable carries a bloom filter, so the reader skips any tier that cannot hold the key with no disk read, making most reads far cheaper than this worst case.
Notes ↗
M3

IO Cost & Trade-offs: Choosing Your Index

Choose an index by answering three questions.

1 figure
io-indexes · Q2. What if the data is sorted?

Clustered vs unclustered: the same range query

Clustered vs unclustered: the same range query The same query for every rating of 4 or 5, over the same four matching rows. On a clustered table (sorted by rating) the matches sit in two contiguous pages, so it is two sequential reads and a sparse index. On an unclustered table (insert order) the same matches scatter across all four pages, so it is one random read per row and a dense index. Color key: a green left-bar marks an in-range row and a page that gets read; grey is out of range. Clustered vs unclustered: same query, same matches Find every rating of 4 or 5. Sorting packs the matches together; insert order scatters them. CLUSTERED · table sorted by rating Page 1 rating 1 rating 2 Page 2 rating 2 rating 3 Page 3 rating 4 rating 4 Page 4 rating 5 rating 5 2 reads · sequentialsparse index: one entry per page UNCLUSTERED · insert order Page 1 rating 4 rating 2 Page 2 rating 1 rating 5 Page 3 rating 4 rating 3 Page 4 rating 5 rating 2 4 reads · random, one per rowdense index: one entry per row green bar = an in-range row, on a page that gets read  ·  grey = out of range
A range query for rating 4 or 5 matches four rows. A clustered table stores rows sorted by rating, so the matches occupy two contiguous pages and require two sequential reads. The index stores one entry per page (sparse). An unclustered table stores rows in insert order, so the four matches occupy four pages and require one random read per matching row. The index stores one entry per row (dense). Green marks an in-range row and a page that gets read.
Notes ↗
case-study-spotify-search · The Solution: An Inverted Index

Inverted index

Inverted index Inverted index. On the left, a small B+Tree serves as the term dictionary, with every unique word in the Songs table as a leaf entry. Crazy and Heart are the highlighted terms, shown in violet. On the right, each term points at its postings list, a packed array of song IDs. A bitwise AND intersection in RAM between the Crazy and Heart postings returns the two song IDs that contain both words, 12 and 108. Color key: green is the dictionary and postings on disk (persistent), red strip is the bitwise AND in RAM (volatile), violet is the traced terms and IDs. Inverted index: term dictionary and postings lists B+Tree of unique words on the left · packed arrays of song IDs on the right Term dictionary (B+Tree) · disk every unique word in the Songs table Root Internal Internal Crazy Music Hello Heart Love Yesterday … 100 million more terms Postings lists (contiguous arrays) · disk each term points at the song IDs that contain it Postings for "Crazy" 12 45 108 145 201 Postings for "Heart" 8 12 88 108 180 Bitwise AND intersection (in RAM) walk both sorted lists in lockstep, keep only IDs appearing in both Crazy: 12 45 108 145 201 Heart: 8 12 88 108 180 intersect Result 12 108 Query "Crazy Heart": 2 term lookups + 2 postings reads + 1 intersection + 2 data-page reads ≈ 10 IOs vs. 100,000,000 IOs for a full-table scan of the 100-million-song Songs table ~10,000,000× fewer reads for the same answer Color key  green = dictionary and postings on disk  ·  red strip = the AND in RAM  ·  violet = traced terms and IDs
An inverted index over the Songs table uses a term dictionary on disk, implemented as a B+Tree over every unique word, and postings lists on disk, stored as sorted packed arrays of song IDs per word. For "Crazy Heart" the engine looks up each word, intersects the two sorted postings lists in RAM to {12, 108}, and reads those 2 data pages, about 10 IOs. A full-table scan reads all 100 million rows, so the index uses roughly 10,000,000× fewer reads.
Notes ↗
M3

Case Study 3.3: Tracking High-Volume Activity with LSM Trees

Spotify tracks billions of "like" and "play" events using LSM-tree-backed key-value stores (Cassandra, Bigtable).

1 figure
case-study-lsm-spotify

Spotify's LSM write and read path: events append to a MemTable, flush to immutable SSTables, then compact

Spotify's LSM write and read path: events append to a MemTable, flush to immutable SSTables, then compact Spotify routes a firehose of like, play, and pause events into an LSM-backed store. Every event is a fast append into an in-memory MemTable (red, the only volatile part), so no random disk seek ever happens on the write path. When the MemTable fills, it is flushed sequentially as an immutable SSTable on disk (green), and a background compaction merges old SSTables, applying the merge function and dropping superseded versions and tombstones (grey). A read scans newest to oldest, MemTable first then SSTables in reverse order, and returns the first match. Color key: red is the in-RAM MemTable (volatile), green is the SSTables on disk (persistent), grey is compaction and superseded versions. Spotify's write path never seeks: append, flush, compact Millions of events per second append to RAM, flush sequentially to immutable files, and merge in the background. Event firehose like (song 42, +1) play (song 42, +1) pause (podcast 7, t=92:14) 700M users, no write locks MemTable in RAM, sorted, append-only song 42 → +1, +1 podcast 7 → t=92:14 fills up, then flushes append SSTables on disk immutable, newest on top SSTable 3 song 42 → +1, +1 · ⊘ song 9 SSTable 2 podcast 7 → t=92:14 (latest) SSTable 1 podcast 7 → t=14:00 (superseded) flush Compaction (background) merge fn: SUM · pick-latest · append drops old versions and tombstones read: scan MemTable, then SSTables newest → oldest, return first match red = the in-RAM MemTable (volatile)  ·  green = SSTables on disk (persistent)  ·  grey = compaction and superseded versions
Spotify avoids seeks on the write path: each like, play, or pause appends to an in-memory MemTable, then flushes sequentially to an immutable SSTable on disk. Background compaction merges SSTables through the merge function and drops superseded versions and tombstones. A read scans newest to oldest, starting with the MemTable, and stops at the first match.
Notes ↗
M3

Case Study 3.4: The Engineering of "Spotify Wrapped"

Spotify Wrapped computes each user's yearly summary overnight and stores it as a small per-user record, so the December 1 spike reads one indexed row per user.

1 figure
case-study-wrapped-indexing

How indexing makes Spotify Wrapped instant

How indexing makes Spotify Wrapped instant A year of raw listen events on the left is scattered across the disk, so a per-user roll-up by full scan is a thousand random seeks (grey). Clustering the Listens table by (user_id, listen_time) puts each user's plays on one page, so the overnight batch job reads them sequentially and writes one Wrapped summary row per user (green). On December 1 the online card is a single hash-index lookup on user_id into that precomputed table. Color key: green is the clustered index path and the precomputed Wrapped rows, grey is the scattered raw scan that clustering replaces. How indexing makes Spotify Wrapped instant Clustering turns a year of scattered plays into one sequential scan, then the December card is a single lookup. A year of raw listens stored chronologically, your plays scattered Pg 12 user 3 · 09:14 Pg 47 user 7 · you Pg 88 user 1 · 11:02 Pg 213 user 7 · you Pg 250 user 4 · 22:30 Pg 901 user 7 · you 1,000 plays = 1,000 random seeks cluster by (user, time) Clustered Listens + nightly batch your plays now sit on one page, read in order Pg 5 user 7 · 02:11 Pg 5 user 7 · 07:48 Pg 5 user 7 · 18:30 Pg 5 user 7 · 21:05 one sequential scan per user → tally top tracks, minutes runs overnight, before Dec 1 write summary Wrapped table one row per user, keyed on user_id user_id → summary user 1 → {top 5, 8.1k min} user 3 → {top 5, 2.4k min} user 7 → {top 5, 41k min} user 4 → {top 5, 5.7k min} 700M tiny rows, not a year of plays hash index on user_id → O(1) the online lookup target tap user 7 Your Wrapped Top artist: … 41,000 minutes Top 0.5% listener 1 lookup, instant Dec 1, 700M clients Color key  green = the clustered index path and the precomputed Wrapped rows  ·  grey = the scattered raw scan that clustering replaces
Wrapped runs the expensive computation overnight against an index that supports sequential reads. Chronological storage (grey) scatters one user's listens across the disk. Clustering Listens by (user_id, listen_time) places that user's year on one page, so the nightly batch reads it as a sequential pass and writes one summary row per user (green). On December 1 the card fetch uses a single hash-index lookup on user_id.
Notes ↗
M3

Putting It All Together: Saving $550,000 on One Query

A single unindexed weekly aggregation query running 100 times a day on a 1 TB table can cost a company $550,000 a year in cloud compute.

1 figure
case-study-query-disaster · Summary: The Compound ROI

From a $550,000 query disaster to $400: three orthogonal optimizations that multiply

From a $550,000 query disaster to $400: three orthogonal optimizations that multiply The same weekly aggregation in four stages, left to right. The red baseline is a Hash Partition Join scanning the full 1 TB at 49,200 IOs and about $550,000 a year. Adding a B+Tree index cuts the rows scanned (about 90 times fewer), columnar storage cuts the columns read (about 9 times), and compression cuts the bytes stored (about 2 times), so the green final stage is roughly 1,400 times cheaper at about $400 a year. Color key: red is the costly baseline disaster, green is the optimized end, grey is the intermediate steps and structure. Saving $550,000 on one query three orthogonal optimizations multiply to roughly a 1,400× improvement Baseline · HPJ scan all 1 TB, every column 49,200 IOs $550,000 / yr 1× · the disaster ×90 cheaper + B+Tree Index skip to the 1% in date range ~554 IOs ~$6,500 / yr fewer ROWS scanned ×9 cheaper + Columnar Store read only the 4 needed columns ~60 IOs ~$730 / yr fewer COLUMNS read ×2 cheaper + Compression dictionary · bit-pack · RLE ~35 IOs ~$400 / yr fewer BYTES stored bar length = relative annual cost (log scale, illustrative) They stack because each attacks a different, independent kind of waste B+Tree Index fewer ROWS scanned ×90 × Columnar Store fewer COLUMNS read ×9 × Compression fewer BYTES stored ×2 ⊙ Takeaway 90 × 9 × 2 stacks to about 1,400×. The $550,000 / yr disaster collapses to ~$400 / yr. Color key  red = the costly baseline disaster  ·  green = the optimized end  ·  grey = intermediate steps
Four stages of the same weekly aggregation. The baseline Hash Partition Join scans the full 1 TB at 49,200 IOs and about $550,000 a year. A B+Tree index cuts rows scanned (×90). Columnar storage cuts columns read (×9). Compression cuts bytes stored (×2). The combined effect reaches roughly 1,400× and about $400 a year.
Notes ↗
M3

Query Optimizer: Building Execution from Lego Blocks

The query optimizer transforms a SQL query into an executable plan by enumerating possible join orders and access paths, estimating each one's IO cost, and picking the cheapest.

2 figures
query-optimizer · The Journey: SQL to a Plan

How a SQL query becomes the cheapest execution plan

How a SQL query becomes the cheapest execution plan One left-to-right pipeline of five stages. Stage 1: SQL is declarative, it states the result, not the procedure. Stage 2: parse and bind turn the text into a validated tree, resolving names and types against the catalog. Stage 3: the logical plan applies equivalence-preserving relational-algebra rewrites (predicate pushdown, projection pruning, subquery flattening); this defines the result, every rewrite returns the same rows. Stage 4, the focal stage: the physical-plan search picks a join order, join algorithm, and access path, using dynamic programming that computes the best sub-plan for each subset of tables once and reuses it, so it never enumerates all n factorial orders, and costs each from table statistics; this is where speed is decided. Stage 5: the chosen operator tree executes, routinely about 100 times faster than the naive ordering. Because the logical plan and every plan the search considers are result-equivalent, only speed varies, never the answer. Color key: mint is the physical-plan cost search where speed is decided; grey is the parse, rewrite, and execute scaffolding. From SQL to the cheapest plan The logical plan defines the answer; every plan after it is equivalent, so only speed varies 1 · SQL (declarative) SELECT … FROM Users JOIN Listens JOIN Songs WHERE … GROUP BY … says what, not how 2 · Parse + bind text → AST (parse tree) resolve table / column names & types vs catalog syntactically valid 3 · Logical plan algebra: σ π ⋈ γ equivalence-preserving rewrites: predicate pushdown, prune cols, flatten subqueries defines the result 4 · Physical: cost search pick join order, algorithm, access path. Orders blow up: 3!=6 … 10!=3.6M dynamic programming: best sub-plan per table subset, reused (n! → ~2ⁿ); cost each from stats same result, just faster 5 · Execute run the chosen operator tree ~100× vs naive Color key  mint = the physical-plan cost search, where speed is decided  ·  grey = parse / rewrite / execute scaffolding
SQL is declarative: it states the result, not the procedure. Parsing and binding produce a validated tree; the logical plan then applies equivalence-preserving relational-algebra rewrites (predicate pushdown, projection pruning, subquery flattening). "Defines the result" means exactly this: every rewrite, and every plan the next stage considers, returns the same rows, so the answer is now pinned and only running time can still change. The physical-plan cost search decides speed: it chooses a join order, join algorithm, and access path, and because trying all n! orders is infeasible it uses dynamic programming, computing the cheapest sub-plan for each subset of tables once and reusing it (so n! work collapses to about 2ⁿ), with each candidate costed from table statistics. Only the winner executes, routinely about 100 times faster than the naive ordering.
Notes ↗
query-optimizer · Algorithm Lego Blocks

Algorithm 'lego blocks' the optimizer can mix and match into physical plans

Algorithm 'lego blocks' the optimizer can mix and match into physical plans Algorithm 'lego blocks' the optimizer can mix and match into physical plans. Four columns: Access Methods (sequential, index, bitmap scans), Partition / Sort (Hash Partition, BigSort), Aggregation (Hash Aggregate, Sort Aggregate), Join Algorithms (Hash Join, Sort-Merge Join, Block Nested Loop). This is a neutral catalog: the four families are equal peers, so no color coding is used. Algorithm Lego Blocks the optimizer combines these to build a physical plan 1. Access Methods how to fetch rows Sequential Scan read every page good for > 10 % rows Index Scan B+Tree lookup good for < 1 % rows Bitmap Scan combine multiple indexes good for 1 – 10 % rows 2. Partition / Sort break big into small Hash Partition split file by h(key) % B cost: 2N IO BigSort external merge sort cost: 2N · (1 + ⌈log_B (N/2B)⌉) 3. Aggregation collapse to one row per group Hash Aggregate in-memory hash table no sort needed Sort Aggregate sort, then group adjacent rows good if input already sorted 4. Join Algorithms combine two tables Hash Join (HPJ) build hash, probe cost: 3 (N + M) Sort-Merge (SMJ) sort both, walk in lockstep cost: BigSort + N + M Block Nested Loop load R, scan S per block cost: P(R) + ⌈P(R)/B⌉·P(S) A neutral catalog: four equal families the optimizer mixes and matches, so no color coding is used.
The optimizer assembles a physical plan from four families of interchangeable blocks: access methods (sequential, index, and bitmap scans), partition and sort (Hash Partition, BigSort), aggregation (Hash Aggregate, Sort Aggregate), and join algorithms (Hash Join, Sort-Merge Join, Block Nested Loop). Any plan is one choice from each family wired together, so plan selection is a cost comparison across those combinations. Each block is a primitive with its own page.
Notes ↗
M3

Selectivity & Statistics: How Databases Estimate Data Flow

Selectivity is the fraction of rows a predicate keeps; the database's statistics (histograms, distinct-value counts) let the query planner estimate selectivity to choose the cheapest plan.

2 figures
selectivity-statistics · Step 1: Gathering Statistics

Reservoir sampling: a uniform sample in one streaming pass

Reservoir sampling: a uniform sample in one streaming pass Three panels left to right. Panel 1, the stream: rows arrive one at a time in a single pass, row 1 to row N. Panel 2, the reservoir of k equals 3 slots, the focal element: the first k rows fill the slots; for each later row i the algorithm draws j uniformly in 1 to i, and if j is at most k that row overwrites slot j (probability k over i), otherwise the row is discarded (probability 1 minus k over i). Panel 3, the guarantee: after N rows the reservoir holds k rows using O(k) memory in one pass, and every one of the N rows is in the final sample with equal probability k over N. Color key: mint is the fixed-size reservoir and the keep rule; grey is the stream and the discarded rows. Reservoir sampling: a uniform sample in one pass Keep k. For row i, swap it in with probability k/i. Every row ends up equally likely. 1 · The stream row 1 (fills slot) row 2 (fills slot) row 3 (fills slot) row i … up to row N one pass, no rewind 2 · The reservoir · k = 3 slot 1 slot 2 slot 3 First k rows fill the k slots. Row i (i > k): draw j uniform in 1…i. j ≤ k → row i overwrites slot j (prob k/i) else → discard row i (prob 1 − k/i) O(k) memory, one pass, no second scan 3 · The guarantee After N rows: k rows held, one pass. Every one of the N rows is in the sample with equal prob. P(in sample) = k/N Color key  mint = the fixed-size reservoir and keep rule  ·  grey = the stream and discarded rows
Reservoir sampling is the default when you need an unbiased uniform sample in one streaming pass over a table too big to shuffle. Keep the first k rows; for each later row i, swap it into a random slot with probability k/i, otherwise discard it. The result is exact: every one of the N rows is in the final sample with equal probability k/N, using only O(k) memory and no second pass. Two cheaper alternatives trade exactness for speed: block sampling reads whole pages (fast I/O, but biased when rows are clustered on disk), and progressive sampling starts small and grows until the estimate's variance is low enough (cheap when the data is easy, more passes when it is not).
Notes ↗
selectivity-statistics · Step 2: Storing Statistics

Equi-width vs height-balanced histograms on skewed data

Equi-width vs height-balanced histograms on skewed data Two panels over the same right-skewed column. Left, equi-width: every bucket spans an equal value range, so on skewed data the row counts per bucket vary wildly and one bucket swallows almost all the rows, making its selectivity estimate useless. Right, height-balanced, the focal panel: every bucket holds the same number of rows (here 25 percent each), so bucket ranges shrink where the data is dense and widen where it is sparse, keeping estimates accurate under skew. Color key: mint is the height-balanced histogram that survives skew; grey is the equi-width baseline. Two ways to bucket a skewed column Equal value ranges, or equal row counts. Skew breaks the first. Equi-width: equal value ranges 0–25 25–50 50–75 75–100 ~85% of rows One bucket swallows almost everything; its estimate is useless on skew Height-balanced: equal row counts 25% 25% 25% 25% 0–60 …93–100 Ranges shrink where data is dense; every bucket carries 1/B of the rows Color key  mint = height-balanced, survives skew  ·  grey = equi-width baseline
An equi-width histogram splits the value range into equal slices; it is trivial to build but on skewed data one bucket swallows almost all the rows, so any selectivity read off it is wildly wrong. A height-balanced histogram instead gives every bucket the same row count (each holds 1/B of the rows), so bucket ranges automatically shrink where data is dense, which keeps estimates accurate exactly where skew would otherwise hurt. A third kind, the wavelet histogram, stores a multi-resolution summary for complex or multi-dimensional distributions.
Notes ↗
M3

Reading PostgreSQL EXPLAIN

EXPLAIN shows the physical plan a real optimizer chose for a query: every operator, every cost estimate, every row count, read bottom-up.

1 figure
postgres-explain · Worked Example: What PostgreSQL Chose

PostgreSQL EXPLAIN output for the popular-songs query

PostgreSQL EXPLAIN output for the popular-songs query PostgreSQL's actual EXPLAIN output for the popular-songs query, reproduced verbatim from the course colab (Limit cost 55.21 to 55.24). Ten plan lines read bottom-up: a Seq Scan on songs feeds a Hash, a Seq Scan on listens probes the Hash Join on song_id, a HashAggregate groups by title and artist, a Sort orders by count descending, and a Limit takes the top ten. Each operator line shows the operator name in green and the optimizer's own cost=startup..total, rows, and width estimate in grey. Color key: green is the operator PostgreSQL chose, grey is its cost estimate and the plan tree structure. PostgreSQL EXPLAIN: the popular-songs plan the optimizer's real plan, read bottom-up (leaves first) read [01] -> Seq Scan on songs (cost=0.00..11.10 rows=110 width=440) [03] -> Seq Scan on listens (cost=0.00..24.50 rows=1450 width=4) [02] -> Hash (cost=11.10..11.10 rows=110 width=440) [05] -> Hash Join (cost=12.47..40.86 rows=1450 width=440) [04] Hash Cond: (listens.song_id = songs.song_id) [07] -> HashAggregate (cost=51.73..52.83 rows=110 width=444) [06] Group Key: songs.title, songs.artist [10] Limit (cost=55.21..55.24 rows=10 width=444) [09] -> Sort (cost=55.21..55.49 rows=110 width=444) [08] Sort Key: (count(listens.song_id)) DESC [NN] green = the operator PostgreSQL chose  ·  grey = its cost=startup..total / rows estimate  ·  read bottom-up
PostgreSQL's actual EXPLAIN output for the popular-songs query, reproduced verbatim from the course colab and read bottom-up: a Seq Scan on songs builds a hash, a Seq Scan on listens probes the Hash Join on song_id, a HashAggregate groups by title and artist, a Sort orders by count, and a Limit takes the top ten. Each line shows the operator (green) and the optimizer's own cost=startup..total and rows= estimate (grey); these are PostgreSQL's estimates on the loaded table, so read the shape of the plan rather than the absolute numbers.
Notes ↗
M3

Distributed Query Planning

A distributed query splits work across many machines by partitioning the input tables, executing local plans on each shard, and shuffling intermediate results so matching keys land together.

1 figure
distributed-query · What is "Shuffle"?

Distributed shuffle: rows starting on multiple nodes are hashed on the join key and sent over the network so all rows with the same key land on the same target node, ready for local join

Distributed shuffle: rows starting on multiple nodes are hashed on the join key and sent over the network so all rows with the same key land on the same target node, ready for local join Distributed shuffle: rows starting on multiple nodes are hashed on the join key and sent over the network so all rows with the same key land on the same target node, ready for local join. This is data movement across machines, not a disk or RAM story. Color key: mint follows one key (user_id 42) from every source node, over the network, onto its single colocated node; grey is the other keys and the node structure. Distributed Shuffle: hash partitioning over the network same h(key), but the destination is another machine, not a local file Before shuffle: rows scattered across 3 nodes Node A user_id 42 · row user_id 7 · row user_id 13 · row Node B user_id 7 · row user_id 42 · row user_id 13 · row Node C user_id 13 · row user_id 7 · row user_id 42 · row ↓ shuffle: send each row to h(user_id) % 3 over the network ↓ After shuffle: same key on the same node, ready for local join Node A · all user_id 42 42 · 42 · 42 → local join Node B · all user_id 7 7 · 7 · 7 → local join Node C · all user_id 13 13 · 13 · 13 → local join Color key  mint = one key (user_id 42) traced from every node onto its colocated node  ·  grey = the other keys and structure
Rows begin scattered across multiple nodes. Hashing each row on the join key picks its target machine, so every row sharing a key travels over the network to the same node. Once the keys sit together, each node runs an ordinary local join on the rows it now owns. This is exactly hash partitioning with the network as the destination, so the cost is the same 2N IO plus the network transfer.
Notes ↗
M3

Spotify End-to-End: How Everything Connects

A single click on Spotify's play button is recorded, reshaped, and analyzed by three different storage strategies in turn: a write-optimized store for the click, a batch pipeline that reshapes it, and a scan-optimized warehouse th

1 figure
spotify-architecture-end-to-end · The Journey

Spotify end-to-end architecture: one click triggers three systems

Spotify end-to-end architecture: one click triggers three systems Spotify end-to-end architecture: one click triggers three systems. Act 1 OLTP (LSM tree write), Act 2 ETL (Spark hash partition, BigSort, broadcast join, columnar write), Act 3 OLAP (star schema query with broadcast and columnar scan). All three are persistent storage systems on disk. Color key: mint marks the durable on-disk storage system at each act; red marks the volatile RAM MemTable on the OLTP hot path before it persists to disk. Spotify: One Click, Three Systems OLTP → ETL → OLAP. Opposite optimizations at each stage. Act 1 · The Click OLTP · user taps play on "Anti-Hero" LSM Tree Write 1. MemTable (in RAM) 2. Flush to SSTable (disk) 3. Background compaction no random disk I/O on hot path < 8 ms · 100K writes/sec · 8.6B/day lsm-indexes OLTP real-time events queue Act 2 · The Transform ETL · nightly Spark batch Spark Pipeline 1. Hash partition by user_id 2. BigSort by timestamp 3. Broadcast join dim tables 4. Write columnar (Parquet) format conversion: row → columnar 3 hr · 1000 nodes · 100 TB/day hash-partitioning · big-sort ETL nightly batch load Act 3 · The Dashboard OLAP · CEO opens app Star Schema Query 1. Broadcast small dims 2. Columnar scan fact 3. Two-phase aggregation 4. Final sort + LIMIT scan only the columns the query needs 2 sec · 3 of 20 cols · 1 PB warehouse storage-layout · distributed-query OLAP Opposite optimizations at opposite ends of the pipeline. Writes optimized for low latency. Scans optimized for compressed throughput. ETL bridges the two. Color key  mint = the durable on-disk storage system at each act  ·  red = the volatile RAM MemTable on the OLTP hot path before it persists
A single tap on play fans out into three systems in sequence. Act 1 is the OLTP write that appends the play to an LSM tree; Act 2 is the ETL pipeline where Spark hash-partitions and BigSorts the events, a broadcast join enriches them, and Spark writes the result columnar; Act 3 is the OLAP query that scans the columnar star schema with a broadcast join to answer the dashboard in about two seconds. Every block in this path is a primitive built earlier in the course, now wired end to end.
Notes ↗
M3

Module 3 Capstone: Picking the Right Algorithm

Real systems combine several data structures.

1 figure
problem-solving-modular-systems

The Module 3 toolbox: one tool per problem

The Module 3 toolbox: one tool per problem Five Spotify workloads, each matched to the Module 3 structure that fits it: date-range queries to a B+Tree, similarity clustering to LSH, hyper-local zones to H3 geo-hashing, a write surge to an LSM tree, and a viral surge to hash partitioning. Each row names the problem, the tool in a green chip, and why it fits. Color key: green marks the chosen tool. The Module 3 toolbox: one tool per problem One Spotify service, five workloads. No single structure wins them all, so you match each problem to its tool. THE WORKLOAD TOOL WHY IT FITS Date-range queries over trillions of listens B+Tree keys stay sorted: jump to the start, scan Cluster similar users and songs by feature LSH similar vectors land in one bucket Pinpoint hyper-local fan zones over 100 fans H3 geo-hash a hierarchical grid you can GROUP BY Absorb a live-drop write surge, 50k at once LSM tree random writes become sequential flushes Spread a viral surge across the cluster Hash partitioning balances load, avoids hot spots
The five workloads below and the structure each one calls for, from a B+Tree for date ranges to hash partitioning for a viral surge. Every row pairs a Spotify problem with the tool that fits its access pattern and the one reason it fits. Green marks the chosen tool.
Notes ↗
end of lecture 1~64 min of figures
M3 nanoDB: storage, algorithms, indexing, optimization 1 of 31
Notes ↗ Video ▶ Why ◎ Schedule ↗ □ keys

M3 nanoDB: storage, algorithms, indexing, optimization

How a database actually runs your query · 31 figures · ~64 min · 1 lectures at this pace