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)
Stores
All of a row contiguously
All 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 at
Scanning one column across many rows — you read every other column too
Fetching or updating a single complete row
Compression
Modest
Excellent — adjacent values share a type and often repeat, so run-length and dictionary encoding work extremely well
Examples
Postgres, MySQL, Oracle
ClickHouse, 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.
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 index
Secondary (non-clustered)
Leaves contain
The rows themselves — the table is the index
The key plus a pointer to the row
How many per table
One (physical order can only be one thing)
Many
Range scan on the key
Very fast — rows are physically adjacent
Random I/O to fetch each row
Extra lookup
None
InnoDB: 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
Type
For
Covering index
Include 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 / filtered
WHERE status = 'pending' on a table that's 99% completed — a tiny index for a hot query.
Expression index
CREATE INDEX ON users (LOWER(email)) — makes WHERE LOWER(email) = ? indexable.
Huge, 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
Algorithm
How
Cost
Chosen when
Nested loop
For each row of the outer table, look up matches in the inner
O(n × m), or O(n log m) with an index on the inner side
The 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 join
Build a hash table on the smaller input, then probe it with the larger
O(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 join
Sort both inputs, then walk them together
O(n log n + m log m), or O(n + m) if already sorted
Both 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 firstEXPLAIN 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."