Search Tech Journey

Find topics, journeys and posts

6-month learning plan41 / 130
back to blog
data engineeringbeginner 55m read

S041 · Indexes — B-Tree Intuition, When to Add

The single biggest performance lever in any database. Learn the B-tree ‘book index’ intuition (~4 disk reads instead of 4 billion), the write tax you pay for every index, when the planner ignores the index (low selectivity, functions on the column, leading wildcards), the composite-index column-order rule, and the CREATE INDEX CONCURRENTLY story every senior engineer has learned from a locked-table outage.

🗃️DatabasesM04 · Databases & SQL· Session 041 of 130 90 min

🎯 Read EXPLAIN plans, decide when a B-tree index will help vs hurt, add composite indexes in the correct column order, and use CREATE INDEX CONCURRENTLY without locking prod.

Why this session exists

Adding the right index is the difference between a 400 ms query and a 0.4 ms query — a thousandfold speedup for one line of DDL. Adding the wrong index taxes every INSERT/UPDATE forever and takes disk. Missing an obvious index causes 4 AM pages when traffic doubles. Adding an index during peak traffic locks the table and takes the site down. This session is the intersection of ‘massive read wins’, ‘subtle write costs’, and ‘the operational footgun that has broken every senior engineer at least once’. Miss it and you'll spend years being the reason your team's dashboards are slow.

You will be able to
  • Explain the B-tree mental model: shallow, wide, sorted, ~4 reads for any lookup even at a billion rows.
  • Read Seq Scan vs Index Scan in EXPLAIN and identify which one your query got.
  • Predict which queries an index helps (point lookups, ranges, ORDER BY) vs cannot help (functions, leading wildcards, low selectivity).
  • Pick the correct column order for a composite index using the left-prefix rule.
  • Add an index to a hot table safely with CREATE INDEX CONCURRENTLY (Postgres) and verify it isn't INVALID.

Prerequisites



(a) Intuition · 5 min

A 900-page cookbook with (and without) an index at the back
🌍 Real world

Imagine a 900-page cookbook. Someone asks: ‘find every recipe with saffron.’ Without a table of contents or back-index, your only option is to open page 1 and read every page — minutes of skimming, guaranteed to miss things. That's a full table scan.

Now the same book with an alphabetical index: ‘saffron … pages 47, 213, 588.’ You flip to the index (itself sorted, so binary-searchable in seconds), grab three page numbers, and jump directly. That's exactly what a database index does. For a table of a billion rows, an index turns a ‘read 1 billion rows’ operation into ~4 disk reads.

💻 Code world

A B-tree is a wide, shallow, self-balancing tree of sorted keys. Each internal node holds ~200 keys, so a tree over 1 billion rows is only about 4 levels deep. Point lookups take ~4 page reads regardless of table size. Range scans walk the leaf level in sorted order — no re-sort needed.

The forever cost: every index doubles down on write work. INSERTing one row into a table with 5 indexes updates 5 trees. Ten indexes = 10× write amplification. Indexes are read-speed insurance you pay for with write speed and disk. The senior mantra: add indexes for queries that run often and hurt; never add ‘just in case’.

The indexing starter pack
  • B-tree — the default index type. Balanced, sorted, ~O(log n) lookups. Handles equality AND range.
  • Hash — O(1) equality lookups, no ranges, no sorting. Rarely worth it in Postgres.
  • GIN — full-text search, JSONB containment, array membership. Bigger index, slower writes, huge read wins for the right query.
  • GiST — geospatial (PostGIS), fuzzy text search, range types. Specialised.
  • BRIN — block-range index for huge, naturally-sorted tables (append-only logs). Tiny footprint, coarse-grained.
  1. 1972
    Bayer & McCreight publish the B-tree paper
    The original ‘B-Trees: Organization and Maintenance of Large Ordered Indexes’. Every RDBMS on Earth still uses this design.
  2. 1979
    Comer's ‘The Ubiquitous B-Tree’ survey
    By this point B-trees are the universal index. Fifty years later, still true.
  3. 1990s
    B+ tree variants dominate
    Leaf-linked B-trees make range scans O(range size + log n). Every serious engine uses this variant.
  4. 2007
    PostgreSQL adds CREATE INDEX CONCURRENTLY
    The one option that lets you add an index to a hot table without locking writes for hours. Every prod war story ends with ‘next time we'll use CONCURRENTLY’.
  5. 2013
    PostgreSQL 9.3 adds materialised views + BRIN in 9.5
    BRIN opens tiny indexes on huge append-only tables (log tables, event streams). One of the underused wins.
  6. 2020s
    MySQL & Postgres add hash + include-columns tuning
    `INCLUDE` columns turn any B-tree into a covering index — the query never touches the heap. Massive win when supported.

(b) Visual walkthrough · 15 min

A tiny B-tree, hand-drawn

Fifteen rows keyed on user_id:

Lookup user_id = 50:

  1. Read root. 50 falls between 40 and 60, so descend into node C.
  2. Read node C. 50 matches; follow the row pointer to disk.
  3. Done — 2 reads, not 15.

Scale this to 1 billion rows with a branching factor of ~200 per node: the tree is only ~4 levels deep. Every lookup is ~4 page reads, regardless of table size. That is the whole magic.

When an index helps vs hurts

Index HELPS ✅

Point lookups, ranges, sorted retrieval

  • `WHERE email = 'a@b.com'` — point lookup, huge win
  • `WHERE created_at > '2026-01-01'` — range scan on sorted tree
  • `ORDER BY created_at LIMIT 10` — tree is already sorted; no sort step
  • `JOIN … ON t2.id = t1.parent_id` — index on t2(id) enables index nested-loop join
  • `SELECT MAX(x)` — walk to the rightmost leaf, done
Index USELESS ❌

Function on column, leading wildcard, low selectivity

  • `WHERE UPPER(email) = 'X'` — function defeats B-tree (use functional index)
  • `WHERE email LIKE '%gmail.com'` — leading wildcard, no anchor
  • `WHERE country = 'US'` on 90-% US data — planner skips it
  • `SELECT *` no WHERE — full scan is unavoidable
  • Tiny tables (< 10 k rows) — full scan is faster than index lookup + heap fetch

The selectivity rule of thumb

An index is worthwhile when it filters out ~95 % or more of rows. If your query returns half the table, a sequential scan is often faster than millions of random tuple pointers followed by random-I/O heap fetches. The planner knows this and will ignore your beautifully-designed index if the statistics say the scan is cheaper. Run ANALYZE after big data loads so the planner has fresh statistics.

Composite indexes — the column-order rule

An index on (a, b, c) is a B-tree keyed on the concatenation of a, b, c in that order. That means:

1✅ ideal
1. Query filters on `a`

Index used — perfect leading match.

2✅ ideal
2. Query filters on `a AND b`

Index used — leading prefix match, both keys narrow the scan.

3✅ ideal
3. Query filters on `a AND b AND c`

Index fully used — all three columns narrow the scan.

4❌ skipped
4. Query filters on `b` alone

Index NOT used — no leading `a` value to binary-search from. Reads like ‘find recipes on pages ending in 47’.

5⚠️ partial
5. Query filters on `a AND c` (skip b)

Only the `a` part of the index is used; `c` is checked row-by-row after the initial narrow. Partially useful.

Rule: put the most-filtered / equality columns first, range columns last. WHERE country = 'US' AND created_at > '2026-01-01' wants (country, created_at), not (created_at, country).

The write tax — what you pay for every index

Cost of an index on every INSERT / UPDATE / DELETE

Extra disk write
The new key is inserted into the B-tree's leaf page. If the leaf is full, page-split cascades up the tree.
disk
WAL amplification
Every index update logs to the write-ahead log — 5 indexes = 5× the WAL volume, which affects replication lag.
wal
Lock contention
Concurrent inserts to the same index page contend for the same buffer latch. Hot indexes on `(created_at)` can become bottlenecks.
lock
Storage
Each B-tree is typically 20–50 % the size of the table's data. A table with 5 indexes stores 2–3× as much data as its rows.
space
Cold-cache pain
Every index competes for shared_buffers. Too many indexes evict useful data pages. Working set doesn't fit → random I/O explodes.
cache

EXPLAIN — the tool you must be fluent in

EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'user_987654@example.com';

Two crucial words: Seq Scan (bad, unless the table is tiny) vs Index Scan / Index Only Scan (good). Read the ‘cost’ and ‘actual time’ numbers. A 1000× drop in actual time after adding an index is the smoking gun for ‘the index worked’.


Common misconception
✗ What most people think

"Queries are slow because there's no index. Add an index on the column in the WHERE clause and it'll be fast."

✓ What is actually true

An index only helps if it is selective — if it eliminates most of the table. When a predicate matches a large fraction of rows, the optimiser will correctly ignore your index and do a full scan, because reading the index and then jumping to each row is slower than reading the table sequentially. Below roughly a few percent selectivity the index wins; above it, the random I/O of row lookups loses to sequential scanning.

Why the myth is so sticky

Because "index = fast lookup" is true for the case you learned it on: a primary key lookup returning one row out of millions. That is maximal selectivity. The intuition then generalises badly to a boolean column, a status flag, or a country code where most rows share one value — indexing those costs write throughput and storage and delivers nothing, because the optimiser will not use them.

Prove it to yourself

Ask the engine rather than guessing — the plan tells you whether the index was used:

-- selectivity of a candidate index column
SELECT COUNT(DISTINCT status)::float / COUNT(*) AS selectivity FROM orders;
-- near 1.0 = highly selective (good index); near 0 = few distinct values (usually useless)

EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'shipped';
-- Seq Scan  -> optimiser judged the index not worth it
-- Index Scan -> it was used; compare estimated vs actual rows

-- a predicate wrapped in a function cannot use a plain index:
WHERE DATE(created_at) = '2024-01-01'          -- no index use
WHERE created_at >= '2024-01-01'
  AND created_at <  '2024-01-02'               -- sargable, index usable
From first principles
Start with the question

Why do databases use B-trees rather than binary search trees or hash tables, when both have better theoretical complexity for their respective operations?

  1. 1
    A disk or SSD cannot read one byte — the minimum unit of I/O is a page, typically 4–16 KB, and reading a page costs roughly the same as reading a byte from it.
    forced by · storage hardware and the OS both operate on blocks; the cost is dominated by the access, not by the bytes transferred
  2. 2
    Therefore the metric to minimise is not comparisons but page reads. An algorithm that does more CPU work per page but fewer page reads wins.
    forced by · a page read is many orders of magnitude slower than an in-memory comparison
  3. 3
    A binary tree stores one key per node, so each level costs one page read and eliminates only half the keys — log₂(n) page reads.
    forced by · a node holds one key, so the branching factor is 2 regardless of how big a page is
  4. 4
    A B-tree instead packs hundreds of keys into one node sized to a page, so a single read eliminates all but 1/hundreds of the remaining keys — log₍fanout₎(n) page reads.
    forced by · you are already paying for the whole page, so filling it with keys makes the read exponentially more informative
  5. 5
    With a fanout in the hundreds, even a very large table has a tree only three or four levels deep, and the upper levels stay cached in memory.
    forced by · log base several-hundred grows extremely slowly, and the root and its children are read on every query so they never leave cache
⇒ Therefore

Therefore a B-tree lookup on a huge table typically costs a small handful of actual disk reads, while a binary tree would cost dozens. The B-tree is a binary search tree reshaped to match the hardware's block granularity.

And note what this predicts: hash indexes are faster for exact-match point lookups precisely because they skip the traversal — but they cannot answer range queries, ordered scans, or prefix matches, which is why B-trees remain the default despite being asymptotically worse for point lookups. It also predicts why index-only scans are so valuable: if every column you need is in the index, the engine never touches the table pages at all, halving the I/O.

Mental modelThe book index

A table index is the index at the back of a book: a sorted list of terms, each with page numbers. Looking up a rare term is instant. Looking up a term that appears on 400 of the 500 pages is worse than useless — you would be better off reading the book cover to cover, which is exactly what a full table scan is.

And every time you add a page to the book, you must update the index. That is why indexes are not free: they are a read optimisation paid for on every single write.

  • Indexes trade write cost and storage for read speed. Each additional index makes every insert, update and delete more expensive.
  • A composite index on (a, b, c) serves predicates on a, on (a,b), and on (a,b,c) — a left prefix. It does not help a query filtering only on b.
  • A predicate must be sargable: wrap the column in a function or apply an implicit type cast and the index is unusable. Transform the constant, never the column.
  • A covering index — one that contains every column the query needs — avoids touching the table entirely. Often the single biggest win available.
🔔 Fires when you see

Fire this model the moment you see: a slow query with a selective WHERE · a plan showing a sequential scan on a large table · a foreign key with no index (join and delete performance both suffer) · an ORDER BY + LIMIT that sorts the whole table · write throughput degrading after someone added indexes · a DATE() or UPPER() wrapped around an indexed column.

The tradeoff

A reporting query filters on five different columns depending on the user's selections. Index each column, build composite indexes, or index nothing?

Single-column index per column
+ you gain each individual predicate is supported; some engines can combine several via bitmap index scans; simple to reason about and to add incrementally
− you pay five indexes means five structures updated on every write; and for a multi-column predicate each index is individually unselective, so combining them still reads a lot
pick when predicates arrive singly and unpredictably, writes are infrequent, and the engine supports bitmap/index intersection
Composite indexes for the common combinations
+ you gain a well-ordered composite is dramatically more selective than any of its columns alone, and can cover the query entirely, eliminating table access
− you pay column order matters and only left prefixes are usable, so you may need several composites; each is wide, costly to maintain, and easy to get subtly wrong
pick when you have measured which two or three predicate combinations actually dominate — never speculatively, since the combinatorics explode
No index; use partitioning and columnar layout instead
+ you gain partition pruning skips whole files rather than seeking within them, columnar formats read only the projected columns, and min/max statistics per block give you skipping for free with no write penalty
− you pay only works along the partition and sort keys, so an ad-hoc filter on some other column still scans everything
pick when the workload is analytical and scan-heavy on a columnar store — where indexes in the OLTP sense mostly do not apply
What a senior engineer actually does

Index for the queries you actually run, established from the query log, not from the schema. The common failure is indexing every column that appears in any WHERE clause, which produces a table where writes are slow and most of the indexes are never chosen by the optimiser anyway.

In analytics the answer usually is not an index at all. Partitioning by time and clustering by the dominant filter column gives you file-level and block-level skipping with no per-write index maintenance — and at petabyte scale, skipping files is worth far more than seeking within them.


(c) Hands-on · 25 min

Spin up Postgres in Docker, load 1 M rows, watch the difference an index makes.

#!/usr/bin/env bash
# indexes_lab.sh — 5 minutes of hands-on with a real Postgres.
set -euo pipefail
 
# 1. Start Postgres 16 in a throwaway container
docker run --rm -d --name pg-lab \
  -e POSTGRES_PASSWORD=pw -p 5432:5432 postgres:16
sleep 4
 
# 2. Run the lab
docker exec -i pg-lab psql -U postgres <<'SQL'
-- Create the table
CREATE TABLE users (
    id          bigserial PRIMARY KEY,
    email       text NOT NULL,
    country     text NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now()
);
 
-- Stuff it with 1 million rows
INSERT INTO users (email, country)
SELECT
    'user_' || g || '@example.com',
    (ARRAY['US','IN','DE','JP','BR'])[1 + (g % 5)]
FROM generate_series(1, 1000000) g;
 
-- Update planner statistics
ANALYZE users;
 
-- 3. Baseline: point lookup WITHOUT index
\echo '=== 3. Point lookup WITHOUT index (expect Seq Scan, ~150-400 ms) ==='
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'user_987654@example.com';
 
-- 4. Add B-tree index
CREATE INDEX idx_users_email ON users(email);
 
-- 5. Same query WITH index (expect Index Scan, ~0.1-0.5 ms — 1000x faster)
\echo '=== 5. Point lookup WITH index (expect Index Scan, sub-ms) ==='
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'user_987654@example.com';
 
-- 6. Low-selectivity query — planner IGNORES the index even if we add one
\echo '=== 6. Low selectivity — planner prefers Seq Scan ==='
CREATE INDEX idx_users_country ON users(country);
ANALYZE users;
EXPLAIN ANALYZE
SELECT count(*) FROM users WHERE country = 'US';
 
-- 7. Range scan uses the index nicely (created_at is unique-ish)
\echo '=== 7. Range scan on created_at (add index first) ==='
CREATE INDEX idx_users_created_at ON users(created_at);
EXPLAIN ANALYZE
SELECT * FROM users WHERE created_at > now() - interval '1 second'
ORDER BY created_at DESC LIMIT 10;
 
-- 8. Composite index — column order matters
\echo '=== 8. Composite index (country, created_at) — helps (country=? AND created_at>?) ==='
CREATE INDEX idx_users_country_ts ON users(country, created_at);
EXPLAIN ANALYZE
SELECT * FROM users
WHERE country = 'DE' AND created_at > now() - interval '5 seconds'
ORDER BY created_at DESC LIMIT 100;
 
-- 9. The function-on-column trap
\echo '=== 9. Function on column defeats B-tree (Seq Scan again) ==='
EXPLAIN ANALYZE
SELECT * FROM users WHERE upper(email) = 'USER_500000@EXAMPLE.COM';
 
-- 9b. Fix with a functional index
CREATE INDEX idx_users_upper_email ON users(upper(email));
ANALYZE users;
\echo '=== 9b. With functional index — Index Scan restored ==='
EXPLAIN ANALYZE
SELECT * FROM users WHERE upper(email) = 'USER_500000@EXAMPLE.COM';
 
-- 10. Leading-wildcard trap (still Seq Scan)
\echo '=== 10. Leading wildcard: LIKE %example.com — no B-tree can help ==='
EXPLAIN ANALYZE
SELECT * FROM users WHERE email LIKE '%example.com' LIMIT 5;
 
-- 11. Cost check: index size vs table size
\echo '=== 11. Index sizes are NOT free ==='
SELECT relname,
       pg_size_pretty(pg_relation_size(relid)) AS size
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY pg_relation_size(relid) DESC;
 
SELECT pg_size_pretty(pg_relation_size('users')) AS table_size;
 
-- 12. Unused-index audit (idx_scan = 0 → drop candidate)
\echo '=== 12. Which indexes have never been used? ==='
SELECT indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY idx_scan;
 
SQL
 
# Cleanup
docker stop pg-lab

Anatomy of the script

Step 3 vs Step 5 — the whole story
One `EXPLAIN ANALYZE` before, one after. Look for `Seq Scan` in step 3 (`Rows Removed by Filter: 999999`) and `Index Scan` in step 5 (a single index lookup). Actual time typically drops from ~200 ms to under 1 ms.
before/after
Step 6 — selectivity in action
The `country` index exists but `country = 'US'` matches 20 % of rows. The planner correctly refuses to use it — random I/O for 200 k tuples is slower than one sequential scan of 1 M. Proof the planner is not stupid.
selectivity
Step 7 — range scan on B-tree
`WHERE created_at > …` walks the sorted leaves. `ORDER BY created_at DESC LIMIT 10` doesn't need a sort step at all — the index IS the sort order.
range
Step 8 — composite column order
Try swapping to `(created_at, country)` and re-run — the planner still uses it but only the range part, not the equality. Column order changes which subset of queries the index can serve.
column-order
Steps 9 → 9b — the function trap and its cure
`UPPER(email)` defeats the B-tree because the tree is keyed on `email`, not `UPPER(email)`. The fix is a functional index that stores the transformed value.
functional
Step 10 — leading wildcards are unfixable with B-tree
For real, use trigram indexes (`pg_trgm` extension) or restructure the schema to store the reversed string.
wildcard
Steps 11 & 12 — the write-tax audit
Every index has a size and a hit count. Airbnb / Uber / any big shop periodically drop indexes with `idx_scan = 0` — they're pure write tax with zero read return.
audit
Try itProve the composite-index column-order rule

Continuing in the same psql session:

-- Drop and try the OPPOSITE column order
DROP INDEX idx_users_country_ts;
CREATE INDEX idx_users_ts_country ON users(created_at, country);
ANALYZE users;
 
-- Query A: filter on country only
EXPLAIN ANALYZE SELECT * FROM users WHERE country = 'DE' LIMIT 100;
 
-- Query B: filter on created_at only
EXPLAIN ANALYZE SELECT * FROM users
WHERE created_at > now() - interval '5 seconds' LIMIT 100;
 
-- Query C: filter on both
EXPLAIN ANALYZE SELECT * FROM users
WHERE country = 'DE' AND created_at > now() - interval '5 seconds' LIMIT 100;

You'll see: Query A on (created_at, country) cannot use the index (no leading match on country). Query B does use it (leading created_at). Query C uses it but only the leading range portion. Now recreate as (country, created_at) and re-run — Query A works, Query B doesn't. Column order literally decides which queries the index serves. Choose based on the queries you actually run, not what feels natural.

💡 Hint · Drop and recreate the composite index in both column orders, then compare plans.

(d) Production reality · 15 min

War story Every team, every year — the CREATE INDEX lock story20-minute exclusive table lock during peak traffic
🔥 What broke

An engineer notices a slow dashboard query, opens a psql session, types CREATE INDEX idx_orders_customer ON orders(customer_id); at 2 PM on a Tuesday. Plain CREATE INDEX takes an exclusive lock on the table for the entire build. On a 500-million-row `orders` table this takes 20 minutes. Every write to orders blocks for 20 minutes. Every order in the checkout funnel fails. The on-call gets paged. Someone learns what ACCESS EXCLUSIVE means the hard way.

🧯 The fix

Always CREATE INDEX CONCURRENTLY in Postgres in prod. It takes 2-3× longer wall-clock, but only takes a much weaker lock that allows concurrent SELECT/INSERT/UPDATE/DELETE. If it fails midway (e.g. constraint violation on a unique index), it leaves behind an INVALID index — check pg_indexes and drop it explicitly. Never run CREATE INDEX CONCURRENTLY inside a transaction (it fails); use a schema-migration tool that knows this (Rails' disable_ddl_transaction!, Django's AddIndexConcurrently, Alembic's `postgresql_concurrently=True`).

🎓 Lesson to steal
Every senior engineer has learned this exactly once. Add ‘CONCURRENTLY every prod index build’ to your muscle memory now, before your first outage. And check for INVALID indexes after every migration.
Post-mortem
War story Any team on Postgres — the missing-FK-index catastropheA ‘simple’ DELETE takes 30 minutes and locks the parent table
🔥 What broke

Postgres automatically creates an index on a primary key. It does not automatically index foreign key columns. So if you have orders(id PK, customer_id FK REFERENCES customers), deleting a customer requires Postgres to full-scan the entire orders table for that customer's rows — to check the FK constraint. On a 100 M row `orders` table with no index on `customer_id`, this is minutes per DELETE. Bulk-deleting many customers is a full outage.

🧯 The fix

For every foreign key column, add an index: CREATE INDEX CONCURRENTLY ON orders(customer_id);. Every FK, every table. This is a code-review checklist item. Some ORMs (Rails, Django) auto-add these; some (SQLAlchemy) do not. Audit your production schema with pg_stat_user_tables to spot FK columns without indexes.

🎓 Lesson to steal
PK columns are auto-indexed. FK columns are NOT. Every FK needs its own index unless the parent table is tiny. Add ‘index every FK column’ to your migration template.
War story Airbnb data-platform audit (public engineering blog)~40 % of production indexes were never used by any query
🔥 What broke

Airbnb ran a `pg_stat_user_indexes` audit in the early 2020s and found nearly half of their production indexes had `idx_scan = 0` — they had been created ‘just in case’ or by long-departed engineers for queries that no longer existed. Every one of those indexes was pure write tax: slowing every INSERT, adding to WAL, occupying shared_buffers, and consuming disk.

🧯 The fix

Two-step audit: (1) SELECT * FROM pg_stat_user_indexes WHERE idx_scan = 0 AND indexrelname NOT LIKE '%pkey%'; to find candidates. (2) Before dropping, cross-check with pg_stat statements for queries that MIGHT use it in the future. Drop the rest. Establish a rule that every new index requires a documented query it serves; every quarter, re-audit for zero-usage indexes.

🎓 Lesson to steal
Over-indexing is as dangerous as under-indexing. ‘We might need this someday’ is an anti-pattern. Every index costs writes forever. Audit periodically and drop the freeloaders.

Where this shows up in the rest of the plan

Indexes are the single biggest performance lever in any DB
S042 · Transactions & ACID
MVCC bloat means old index entries pile up. Regular VACUUM keeps indexes healthy.
S043 · Query optimisation
Reading EXPLAIN plans is a whole session on its own. Index Scan vs Bitmap Heap Scan vs Seq Scan.
S045 · Postgres internals
How the B-tree is stored on disk (pages, splits, WAL). What VACUUM actually does.
S054 · Warehouse columnstores
Snowflake / BigQuery don't use B-trees — columnstore + micro-partitions replace them. Different optimisation model entirely.
S065 · Search systems
Elasticsearch inverted indexes are conceptually the same as pg_trgm — sorted term dictionaries.
S087 · Database migrations
CREATE INDEX CONCURRENTLY, schema migration tools, zero-downtime index adds.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

  1. What is an index, in one sentence? (a sorted lookup structure that turns O(n) into O(log n))
  2. When would you use one? (frequent, selective point/range/sort queries)
  3. When would you NOT? (small tables, low selectivity, write-heavy hot paths)

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.