Module 04 · Lesson 02

Schemas and the index that saves the query

A query without an index is a full scan, and a full scan is a latency cliff.

Intermediate 8 min read Updated 2026-11-01

What you will be able to do

  • Explain why an index turns a scan into a lookup
  • Order columns correctly in a composite index
  • Balance read speed against write amplification
  • Choose keys and constraints that keep data correct

What an index is

Rows in a table are stored in no particular order. Without an index, finding the ten rows where user_id = 42 means reading every row — a full scan. An index is a separate sorted structure mapping column values to row locations, so the database descends a tree instead of sweeping the table.

Rows scanned without index
1,000,000
A 1M row table read end to end
Pages read instead
3-4
Tree depth for a million indexed rows
Time saved
10-1000x
Depends on cache and row width
Extra write cost per index
+5-20%
Each insert updates every index

The interview move is to say which query you are optimising. "The hot path is fetching a user's recent orders, so I would index (user_id, created_at DESC) and paginate by keyset on that pair." A bare "add an index" is not a design decision.

Composite order matters

A composite index on (a, b, c) can serve queries filtering on a, on a and b, or on all three — in that prefix order. It cannot efficiently serve a query filtering only on b. This is why the column order is a design decision, not a detail.

QueryIndex (user_id, created_at)Why
user_id = 42 ORDER BY created_at DESC LIMIT 20PerfectEquality on the prefix, sort on the second column
user_id = 42 AND created_at > '2026-01-01'GoodPrefix equality then range
created_at > '2026-01-01'PoorThe leading column is missing; the index cannot be used well
status = 'paid'UnusableNo shared prefix with this index
Covering indexes
If the index contains every column the query needs, the database answers from the index alone and never touches the table. (user_id, created_at, total_cents) lets a recent-orders list render without row lookups. That is often a bigger win than adding a cache.

The write side of the ledger

Indexes are maintained on insert, update, and delete. Ten indexes on a hot table means ten structures to update per write, more memory pressure, and slower vacuuming. On a write-heavy event table, the correct answer is usually a small number of carefully chosen indexes and an append-only design rather than an index for every query.

  • Low-cardinality columns (status with five values) rarely help alone; combine them with a selective column.
  • Partial indexes — index only rows where status = 'pending' — are small and ideal for queues.
  • Text search with LIKE '%term%' cannot use a normal index; that is a search engine or trigram index conversation.
  • Index bloat from updates is real: watch size growth, not just query plans.

Schema decisions that age well

Keys
Use a stable key that never changes meaning. Auto-increment ids are compact and ordered; random UUIDs distribute better across shards but cost 16 bytes and fragment B-tree pages. UUIDv7 gives you both ordering and uniqueness.
Constraints
Unique constraints and foreign keys are correctness enforced at the right layer. If you remove them for speed, you have taken on an application-level invariant that must survive concurrency.
Timestamps and soft deletes
created_at, updated_at, and deleted_at support auditing, pagination, and recovery. Every query then needs deleted_at IS NULL, so a partial index handles it cleanly.
Money and precision
Store currency as integer minor units. Floating point for money is a bug waiting for a rounding complaint.
Instead
Indexing every column 'just in case'
Do this
Index the queries you have, and remove unused ones — writes and memory thank you
Instead
Using a random UUID as a clustered primary key on a write-heavy table
Do this
Use time-ordered ids (UUIDv7 or a composite of time and random) to avoid page splits
Instead
Assuming an index helps any query mentioning the column
Do this
Check the prefix rule and the query plan; a function or cast on the column usually defeats the index

Worth remembering

  • An index is a sorted structure on a column, so range and equality lookups avoid scanning the table.
  • In a composite index, put equality columns first and the range or sort column last.
  • Every index adds work to every write. Index what your queries need, not what might be nice.
  • Pick primary keys that are stable and reasonably distributed — UUIDs cost space and page splits.

Checkpoint

Answer all 3 questions to finish the lesson.
1. `SELECT ... WHERE user_id = ? ORDER BY created_at DESC LIMIT 20` — which index serves it best?
Equality on the leading column narrows to one user, then the second column provides the sort order so the limit reads the first 20 entries.
2. What is the main cost of adding many indexes to a write-heavy table?
Indexes are extra structures maintained on every insert and update. More indexes mean more write amplification and more cache to keep warm.
3. A covering index helps because:
If every needed column lives in the index, no table lookup is required — fewer pages read, lower latency.