DBDBMS

Block C · Topics 15–17

Internals

Why your query is slow. Three topics — how data sits on disk, how indexes find it, and how the optimiser decides — and together they answer nearly every performance question you'll be asked.

15

Storage & the buffer pool

pagesheap filesbuffer poolrow vs column
Why pages

Disks and the OS transfer fixed-size blocks, not individual rows, so the database organises everything into pages — 8 KB in Postgres, 16 KB in InnoDB. This single fact explains an enormous amount: why narrow rows are faster to scan (more fit per page, so fewer I/Os), why a row bigger than a page needs overflow storage, and why "read one row" always costs at least one page read.

Page layout (roughly universal)
┌────────────────────────────────────────────┐
│ header: checksum, free-space pointers, LSN │
├────────────────────────────────────────────┤
│ slot array →  ptr ptr ptr ptr …            │  ← indirection, so a row
├────────────────────────────────────────────┤    can move within the page
│                free space                 │    without changing its ID
├────────────────────────────────────────────┤
│  … row  row  row  row                      │  ← rows fill from the end
└────────────────────────────────────────────┘

The slot array is why a row identifier stays stable while the row is
reorganised inside its page — and why compaction after deletes is cheap.

Big values (a long TEXT, a blob) don't fit in one page, so they go to
overflow storage — TOAST in Postgres. Consequence worth knowing: a table
with a wide TEXT column can be fast to scan if you don't SELECT that
column, because the main page holds only a pointer. That is a real
argument for naming your columns instead of SELECT *.

The buffer pool

The database keeps recently-used pages in memory and manages that cache itself. This is the single most important performance structure in a database: a page found in the buffer pool costs ~100 ns, and one that must be read from SSD costs ~100 µs — a thousandfold difference. A healthy OLTP system runs above a 99% buffer hit rate, and a dropping hit rate is usually the first sign of trouble.

Why not just use the OS page cache?

Because the database knows things the kernel can't. It knows a sequential scan's pages will not be reused, so it can avoid evicting the hot index pages — the exact scenario where plain LRU fails badly. It knows which pages are dirty and must be written before eviction, and it must control that ordering to keep the write-ahead log correct. And it can pin pages in memory while a query is using them. So most databases implement their own replacement policy — InnoDB uses a segmented LRU that protects the hot set from scans, Postgres uses a clock-sweep — and this is the same argument as the OS page-cache one layer down, made by a component with better information.

Row store vs column store

Row store (OLTP)Column store (OLAP)
StoresAll of a row contiguouslyAll of a column contiguously
Great at"Fetch this whole order" — one page read gets every field"Average this one column over a billion rows" — reads only that column
Bad atScanning one column across many rows — you read every other column tooFetching or updating a single complete row
CompressionModestExcellent — adjacent values share a type and often repeat, so run-length and dictionary encoding work extremely well
ExamplesPostgres, MySQL, OracleClickHouse, Redshift, BigQuery, Parquet

Hence the standard architecture: a row store for transactions, streaming into a column store for analytics. Running heavy analytical queries against your transactional database is the mistake this separation exists to prevent — those scans evict the buffer pool's hot set and slow down every user-facing query.

16

Indexes

B+ treeclusteredcompositecovering
Why B+ trees specifically

Because a database needs to be fast at three things at once: point lookups, range scans, and ordered iteration — while living on a device where you pay per page read. A B+ tree gives all three with 3–4 page reads for millions of rows, which no other simple structure does.

[ 30 | 70 ] root — separator keys only [ 10 | 20 ] [ 40 | 55 ] [ 80 | 95 ] 3,7 → rows 12,18 → rows 33,38 → rows 44,50 → rows 72,77 → rows 88,99 → rows leaves hold ALL the keys and are linked left→right — which is what makes range scans and ORDER BY cheap internal nodes hold only separators, so fan-out is in the hundreds and the tree stays 3–4 levels deep
B+ tree, not B-tree: data lives only in the leaves, and the leaves form a linked list.
B+ tree vs B-tree vs hash — the comparison they want

vs B-tree: a B-tree stores data in internal nodes too, so internal nodes are larger and fan-out is lower, meaning a deeper tree and more page reads. A B+ tree keeps internal nodes as pure separators — hundreds of keys per page — so a table with millions of rows is 3–4 levels deep, and the root and upper levels stay cached. And because the leaves are linked, a range scan walks sideways instead of re-descending the tree.

vs hash index: a hash index gives O(1) equality lookups and is marginally faster for exact matches — but it supports only equality. No ranges, no ORDER BY, no prefix matching, no MIN/MAX shortcut. Since most real workloads mix equality and range, B+ trees win by default.

Clustered vs secondary

Clustered indexSecondary (non-clustered)
Leaves containThe rows themselves — the table is the indexThe key plus a pointer to the row
How many per tableOne (physical order can only be one thing)Many
Range scan on the keyVery fast — rows are physically adjacentRandom I/O to fetch each row
Extra lookupNoneInnoDB: the leaf holds the PK, so a non-covered query does a second B+ tree descent into the clustered index. Postgres: a heap fetch.

InnoDB always clusters on the primary key, which has two consequences worth stating: a random primary key (UUIDv4) causes page splits and fragmentation on insert because rows land in the middle of the tree, whereas a monotonic key appends cleanly; and a wide primary key is stored in every secondary index, inflating all of them. That's the argument for a narrow, time-ordered surrogate key like a bigint or UUIDv7. PostgreSQL is different — it stores rows in a heap and all indexes are secondary, so it has no clustered index and the tradeoffs shift.

Composite indexes and the prefix rule

CREATE INDEX idx ON orders (customer_id, status, created_at);

Can serve:                                    Cannot serve:
  WHERE customer_id = 1                         WHERE status = 'paid'
  WHERE customer_id = 1 AND status = 'paid'     WHERE created_at > '...'
  … AND created_at > '2026-01-01'               (no leading customer_id)
  ORDER BY customer_id, status, created_at

LEFTMOST PREFIX RULE: the index is sorted by the first column, then by
the second within equal firsts, and so on. So it can't help a query that
doesn't constrain the leading column — same reason a phone book sorted
by (surname, forename) is useless for finding everyone called "Priya".

Column ORDER guidance:
  1. Equality predicates before range predicates. An index on
     (status, created_at) serves `status = 'paid' AND created_at > x`;
     (created_at, status) does not use the status part efficiently.
  2. Put the column you ORDER BY last, so the index provides the sort
     and the planner can skip the sort step entirely.
  3. Higher selectivity earlier, all else equal.

Index types and special cases

TypeFor
Covering indexInclude every column the query needs, so it's answered from the index alone with no table access — an "index-only scan". Often the single biggest win available.
Partial / filteredWHERE status = 'pending' on a table that's 99% completed — a tiny index for a hot query.
Expression indexCREATE INDEX ON users (LOWER(email)) — makes WHERE LOWER(email) = ? indexable.
UniqueEnforces a constraint and serves queries.
HashEquality only. Rarely worth it.
GIN / invertedFull-text search, JSONB containment, array membership.
GiST / R-treeGeometric and geospatial queries.
BRINHuge, naturally-ordered tables (append-only time series) — stores min/max per block range. Tiny index, big win on range scans.
When an index does not help
  • Low selectivity. If a predicate matches more than roughly 5–10% of rows, a sequential scan is usually cheaper — reading sequentially beats thousands of random row fetches. So an index on a boolean that's 50/50 will be correctly ignored.
  • A function or cast on the column. WHERE YEAR(created_at) = 2026 can't use an index on created_at; rewrite as a range.
  • Leading wildcard. LIKE '%term' can't use a B+ tree — the sort order starts at the left.
  • Small tables. A few pages are faster to scan than to traverse.
  • Stale statistics. The index exists and is suitable, but the planner's row estimates are wrong. Run ANALYZE. This is a very common real cause of "why isn't it using my index?"

And always state the cost: every INSERT, UPDATE and DELETE maintains every index. Ten indexes on a hot table means eleven write operations per insert, plus the memory and disk they occupy. Unused indexes are pure tax — find them in pg_stat_user_indexes.

17

Query processing & optimisation

join algorithmscost modelEXPLAINstatistics

From text to result

SQL text
  → parse       syntax check, build a tree
  → bind        resolve names against the catalogue, type-check
  → rewrite     expand views, flatten subqueries, push predicates down,
                  eliminate provably-unneeded joins
  → plan        enumerate access paths and join orders, ESTIMATE the
                  cost of each using statistics, pick the cheapest
  → execute     run the chosen plan, usually as a tree of iterators

SQL is DECLARATIVE — you state the result, not the method — which is
what makes the optimiser possible and why the same query can get faster
after an upgrade without you changing anything.

Join algorithms — the three to know

AlgorithmHowCostChosen when
Nested loopFor each row of the outer table, look up matches in the innerO(n × m), or O(n log m) with an index on the inner sideThe outer side is small and the inner has a usable index. Excellent for OLTP point queries; catastrophic if the planner underestimates the outer row count.
Hash joinBuild a hash table on the smaller input, then probe it with the largerO(n + m)Equality joins on large unsorted inputs. The workhorse for analytics. Needs memory — if the hash table doesn't fit it spills to disk in batches.
Merge joinSort both inputs, then walk them togetherO(n log n + m log m), or O(n + m) if already sortedBoth inputs are already sorted — typically because an index provides the order. Also handles inequality joins, which hash joins can't.
The insight to volunteer

A hash join can't be used for a non-equality condition, and a nested loop is the only general option there — which is why JOIN … ON a.x BETWEEN b.lo AND b.hi is often dramatically slower than an equality join. And the most common cause of a catastrophically slow plan is a bad row estimate: the planner thinks the outer side of a nested loop has 10 rows, chooses nested loop, and it actually has a million — so it performs a million index lookups. That's why comparing estimated against actual rows in EXPLAIN ANALYZE is the first thing to look at.

Cost estimation and statistics

The optimiser is cost-based: it estimates I/O and CPU for each candidate plan from statistics kept about each table — row counts, column cardinality, most-common values, histograms of value distribution, and the fraction of NULLs. Those statistics are what ANALYZE collects, and they go stale after bulk loads or large deletes.

Selectivity is the fraction of rows a predicate is expected to keep, and it drives everything.
WHERE id = 5 on a unique column → selectivity 1/n → index.
WHERE active = true where 90% are active → selectivity 0.9 → sequential scan.

Estimates degrade fastest with correlated columns — the planner assumes independence, so city = 'Mumbai' AND state = 'Maharashtra' is estimated as the product of two selectivities when in reality one implies the other. Extended statistics exist precisely for this.

Reading EXPLAIN

EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, c.name FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at > '2026-01-01';

Hash Join  (cost=340..8210 rows=1200 width=40)
           (actual time=2.1..48.7 rows=98000 loops=1)
  Hash Cond: (o.customer_id = c.id)
  ->  Seq Scan on orders o  (cost=0..6100 rows=1200)
                            (actual time=0.1..30.2 rows=98000)
        Filter: (created_at > '2026-01-01')
        Rows Removed by Filter: 402000
  ->  Hash  (cost=190..190 rows=12000) (actual rows=12000)
        ->  Seq Scan on customers c

Read it INSIDE-OUT and BOTTOM-UP — children run before parents.

Two red flags here:
1. estimated rows=1200 vs actual rows=98000 — an 80× underestimate.
   Statistics are stale, or the date predicate is correlated with
   something. Run ANALYZE; consider extended statistics.
2. Seq Scan on orders removing 402,000 rows by filter — an index on
   created_at would let it read only the 98,000 that match.

What to look for generally:
  · Seq Scan on a big table with a selective filter  → missing index
  · Nested Loop with a huge actual outer row count   → bad estimate
  · Sort with "external merge Disk: ..."             → raise work_mem
  · Hash "Batches: 8"                                → hash spilled to disk
  · Rows Removed by Filter very high                 → index the filter
  · estimated ≫ or ≪ actual                          → fix statistics first

EXPLAIN alone shows the PLAN and estimates.
EXPLAIN ANALYZE actually RUNS the query and shows real timings —
so never run it on a destructive statement outside a transaction
you intend to roll back.
Say this

"I'd start with EXPLAIN ANALYZE and compare estimated to actual rows — if they're wildly different, the plan was chosen on bad information and the fix is statistics, not an index. If the estimate is fine and I see a sequential scan removing most rows by filter, that's a missing index, and I'd choose a composite one ordered with equality columns first and the sort column last so it serves both the lookup and the ordering. Then I'd check whether it can be covering, because an index-only scan avoids the table access entirely. And I'd weigh that against the write cost, since every index is maintained on every insert."