Storage And Indexing
Choosing Indexes
Indexes do not serve columns. They serve QUERIES.
JrCodex·7 min read
Jr Codex DBMS Notes
Level: Intermediate Prerequisites: Chapter 3: Hash and Specialised Indexes Time to complete: ~20 minutes
Table of Contents
- Start From the Queries
- Selectivity
- Sargability
- What to Index by Default
- What Not to Index
- Finding Missing and Unused Indexes
- Summary & Next Steps
1. Start From the Queries
The Wrong Question
─────────────────────────────────────────
"Which columns should I index?"
Indexes do not serve columns. They serve QUERIES.
THE RIGHT QUESTION
"What are my slowest and most frequent queries,
and what would each one need?"
─────────────────────────────────────────
The Procedure
─────────────────────────────────────────
1. LIST the queries — from the slow query log and
from the application's hot paths
2. For each, note the WHERE columns, JOIN columns,
ORDER BY columns and SELECT columns
3. DESIGN one index per query shape, applying
Chapter 3's ordering rules
4. MERGE indexes that share a leftmost prefix —
(a) is redundant if (a, b) exists
5. MEASURE with EXPLAIN before and after
(Module 6, Chapter 4)
─────────────────────────────────────────
-- Step 4, made concrete: these three are redundant.
CREATE INDEX i1 ON orders (customer_id); -- ✗ drop
CREATE INDEX i2 ON orders (customer_id, status); -- ✗ drop
CREATE INDEX i3 ON orders (customer_id, status, created_at); -- ✓ keep
-- i3 serves every query i1 and i2 could, by the leftmost prefix rule.2. Selectivity
The Definition
─────────────────────────────────────────
SELECTIVITY = distinct values ÷ total rows
HIGH (near 1.0) nearly unique — email, id
── excellent index candidate
LOW (near 0) few distinct values — gender,
is_active, status
── usually a poor candidate
─────────────────────────────────────────
SELECT
COUNT(DISTINCT city)::float / COUNT(*) AS city_selectivity,
COUNT(DISTINCT email)::float / COUNT(*) AS email_selectivity,
COUNT(DISTINCT status)::float / COUNT(*) AS status_selectivity
FROM students;Why Low Selectivity Defeats an Index
─────────────────────────────────────────
`status` has 3 values across 1,000,000 rows, so a
lookup matches ~333,000 rows.
Using the index means 333,000 RANDOM heap reads.
A sequential scan reads the whole table in page
order and is FASTER.
The optimiser knows this from its statistics
(Module 1, Chapter 3) and will correctly ignore
your index.
─────────────────────────────────────────
The Two Important Exceptions
─────────────────────────────────────────
SKEWED DISTRIBUTION
status is 99.9% 'complete' and 0.1% 'failed'.
Filtering on 'failed' is highly selective even
though the column is not.
──► a PARTIAL index (Chapter 3) is ideal here.
AS PART OF A COMPOSITE
(status, created_at) is useful even though
status alone is not — status narrows, then
created_at orders within it.
─────────────────────────────────────────
3. Sargability
SARGable = "Search ARGument able" — a condition an index can seek on.
The Rule
─────────────────────────────────────────
The indexed COLUMN must appear ALONE on one side
of the comparison.
The moment it is wrapped in a function or
arithmetic, the index is unusable — the database
cannot know which stored values produce the
target result without checking all of them.
─────────────────────────────────────────
-- ✗ NON-SARGABLE -- ✓ SARGABLE equivalent
WHERE YEAR(created_at) = 2026 WHERE created_at >= '2026-01-01'
AND created_at < '2027-01-01'
WHERE LOWER(email) = 'a@b.com' WHERE email = 'a@b.com'
-- (or index LOWER(email), Chapter 3)
WHERE price * 1.2 > 100 WHERE price > 100 / 1.2
WHERE name LIKE '%son' WHERE name LIKE 'son%'
-- (or a full-text index)
WHERE id + 0 = 42 WHERE id = 42
WHERE COALESCE(city,'') = 'Pune' WHERE city = 'Pune'
-- handle NULL separatelyThe Date Case Is the Most Common
─────────────────────────────────────────
YEAR(created_at) = 2026 appears in an enormous
amount of real code, and silently forces a full
scan on the largest table in the system.
The half-open range is exactly equivalent, uses
the index, and is also correct across time zones
and leap years.
If you check one thing after reading this
chapter, check for functions wrapped around date
columns.
─────────────────────────────────────────
4. What to Index by Default
Almost Always
─────────────────────────────────────────
PRIMARY KEYS
Indexed automatically by every database.
FOREIGN KEYS
NOT automatic in PostgreSQL or SQLite.
Without it, every join on that key scans, and
every parent DELETE scans the child table to
check references.
── the single most commonly missing index
UNIQUE CONSTRAINTS
Automatic — the index is how uniqueness is
enforced.
ORDER BY / pagination columns
Sorting a large result without an index means a
full sort, possibly spilling to disk.
─────────────────────────────────────────
-- Find foreign keys with no index (PostgreSQL).
SELECT c.conrelid::regclass AS table_name, a.attname AS column_name
FROM pg_constraint c
JOIN unnest(c.conkey) AS k(attnum) ON true
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid AND a.attnum = i.indkey[0] -- leftmost column
);5. What Not to Index
Skip These
─────────────────────────────────────────
SMALL TABLES
Under a few hundred rows, a full scan is one or
two pages. The index costs more than it saves.
LOW-SELECTIVITY COLUMNS ALONE
Section 2. Consider partial or composite
instead.
FREQUENTLY UPDATED COLUMNS
Every update rewrites the index entry — and if
the value changes, that means a delete plus an
insert, potentially in a different page.
WIDE TEXT COLUMNS
A 500-byte key destroys the fanout (Chapter 2)
and produces a huge, deep index. Index a hash
or a prefix instead.
COLUMNS NEVER FILTERED, JOINED OR SORTED ON
An index on a column that only ever appears in
SELECT is pure cost.
ANYTHING WITH NO MEASURED QUERY BEHIND IT
The most common mistake: indexing speculatively,
then never removing what did not help.
─────────────────────────────────────────
6. Finding Missing and Unused Indexes
-- 1. Slowest queries by total time (PostgreSQL, needs pg_stat_statements).
SELECT calls, ROUND(mean_exec_time::numeric,2) AS avg_ms,
ROUND(total_exec_time::numeric,0) AS total_ms, query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;-- 2. Tables doing a lot of sequential scanning.
SELECT relname, seq_scan, seq_tup_read, idx_scan,
seq_tup_read / GREATEST(seq_scan,1) AS avg_rows_per_scan
FROM pg_stat_user_tables
WHERE seq_scan > idx_scan AND seq_tup_read > 100000
ORDER BY seq_tup_read DESC;-- 3. Indexes nobody uses — pure write cost.
SELECT relname AS table_name, indexrelname AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND indexrelid NOT IN (
SELECT indexrelid FROM pg_index WHERE indisunique OR indisprimary)
ORDER BY pg_relation_size(indexrelid) DESC;Order Total Time, Not Average
─────────────────────────────────────────
A query taking 5 seconds, run once a day, costs
5 seconds.
A query taking 20 milliseconds, run 2,000 times a
second, costs 40 seconds PER SECOND.
The second is the real problem, and it never
appears in a "slowest queries" list sorted by
average duration. Sort by total.
─────────────────────────────────────────
Before Dropping an Unused Index
─────────────────────────────────────────
- Check statistics have been collecting long
enough to include monthly or quarterly jobs
- Confirm it is not enforcing a UNIQUE constraint
- Check every replica, not just the primary —
read replicas serve different queries
─────────────────────────────────────────
7. Summary & Next Steps
Key Takeaways
- Indexes serve queries, not columns — start from the slow query log and design one index per query shape, then merge those sharing a leftmost prefix.
- Low-selectivity columns defeat indexes because matching many rows means many random heap reads, which a sequential scan beats; skew and composites are the exceptions.
- Wrapping an indexed column in a function makes the condition non-sargable, and a function around a date column is the most common instance in real code.
- Foreign keys are not indexed automatically in PostgreSQL or SQLite, making them the most frequently missing index.
Concept Check
- Why will the optimiser correctly ignore an index on a
statuscolumn with three values across a million rows? - Rewrite
WHERE YEAR(created_at) = 2026to be sargable, and explain why the original is not. - Why should a slow-query list be sorted by total time rather than average?
Next Chapter
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to DBMS Index