You add an index on the column in the WHERE clause. The query is still slow. So you add another one, and another, until the table has eleven indexes, writes have measurably slowed, and the original query still takes four seconds. This is the single most common pattern in database performance work, and it comes from treating indexes as a thing you sprinkle on rather than a data structure with rules.
This is about those rules. What a B-tree actually is, why the order of columns in a composite index decides whether it gets used at all, the specific reasons a perfectly good index gets ignored, and how to read a query plan well enough to know which of those you are looking at.
What a B-tree actually is
Nearly every index you create is a B-tree, specifically a B+tree. It is a balanced tree of fixed-size pages - 8KB in PostgreSQL by default, 16KB in InnoDB - where internal pages hold keys and pointers to child pages, and leaf pages hold the actual index entries in sorted order.
┌──────────────────┐
root │ 40 | 80 │
└───┬───────┬──────┘
┌────────┘ └────────┐
┌──────▼──────┐ ┌──────▼──────┐
inner │ 10 | 25 │ │ 55 | 70 │
└──┬───────┬──┘ └──┬───────┬──┘
┌─────┘ └────┐ │ │
┌───▼───┐ ┌───────┐ ┌▼──────┐ ┌──▼────┐ ┌▼──────┐
│ 1..9 │→ │ 10..24│→ │ 25..39│→ │ 40..54│→│ 55..69│→ …
└───────┘ └───────┘ └───────┘ └───────┘ └───────┘
leaves, in sorted order, linked left to rightWhy it is so fast
Fanout. An 8KB page holding small keys fits hundreds of entries, so each level multiplies capacity by several hundred. Three or four levels covers hundreds of millions of rows, which means finding any single row is three or four page reads - and the upper levels are almost always already in memory.
That is why the difference between an index lookup and a sequential scan is not twenty percent. On a table of ten million rows it is roughly four page reads against tens of thousands.
What a B-tree can and cannot do
| Operation | Can a B-tree help? |
|---|---|
col = value | Yes - descend to the leaf |
col > value, BETWEEN, <= | Yes - descend once, then walk the leaves |
ORDER BY col | Yes - the leaves are already in that order |
col IN (a, b, c) | Yes - several descents |
col LIKE 'abc%' | Yes - a prefix is a range |
col LIKE '%abc' | No - there is no ordering by suffix |
lower(col) = 'x' | No, unless you indexed lower(col) |
col IS NULL | In PostgreSQL yes; nulls are indexed |
col <> value | Technically, but the planner will usually scan instead |
Two storage models, and why the difference matters
This is where PostgreSQL and MySQL diverge in a way that changes index design, and it is skipped in most indexing articles.
PostgreSQL: heap plus separate indexes
Rows live in a heap in no particular order. Every index - including the primary key - is a separate structure whose leaves hold the indexed values plus a tuple identifier pointing into the heap. There is no privileged index.
index leaf: (customer_id=42) → ctid (page 918, offset 3)
↓
heap page 918: [ … row 3: id, customer_id, status, total, … ]InnoDB: the clustered index is the table
In InnoDB the primary key is the table. Rows are stored in primary key order in the leaves of the clustered index. Secondary index leaves do not store a physical pointer - they store the indexed columns plus the primary key value, and reaching the row means a second traversal of the clustered index.
secondary index leaf: (customer_id=42, id=874)
↓
clustered index → descend by id=874 → the full row- Keep the primary key short in InnoDB. Every secondary index carries a copy of it, so a 36-character UUID primary key inflates every other index on the table.
- Primary key order is physical order. Range scans on the primary key are sequential reads; scans on anything else are not.
- Covering indexes are worth more in InnoDB, because they avoid a full second tree traversal rather than a single heap fetch.
- PostgreSQL has no clustered index.
CLUSTERreorders a table once and does not maintain that order, so it is a one-off maintenance operation, not a design choice.
Composite indexes and the leftmost prefix rule
A composite index sorts by the first column, then by the second within equal values of the first, and so on. Picture a phone book sorted by surname then first name: finding everyone called Rahman is easy, finding everyone called Naiem is not.
CREATE INDEX orders_cust_status_placed_idx
ON orders (customer_id, status, placed_at);| Query predicate | Uses the index? |
|---|---|
customer_id = 42 | Yes - leftmost column |
customer_id = 42 AND status = 'paid' | Yes - leftmost two |
customer_id = 42 AND status = 'paid' AND placed_at > … | Yes - the whole index, ideally |
customer_id = 42 AND placed_at > … | Partly - seeks on customer_id, then filters on placed_at |
status = 'paid' | No - skips the leading column |
status = 'paid' AND placed_at > … | No - same reason |
Choosing the order
- Equality columns first, range columns last. Once the index hits a range predicate, everything after it in the index is no longer usable for seeking - only for filtering.
- Then the column you sort by. If
placed_at DESCis your ordering, having it last in the index means the range scan comes out pre-sorted and the sort disappears from the plan. - Selectivity matters less than people say. The old advice to put the most selective column first is secondary to matching your actual predicates. An index whose leading column is not in your
WHEREclause is unused regardless of how selective it is. - Prefer fewer, wider indexes.
(a, b, c)covers three query shapes. Three separate indexes ona,bandccover three, cost three times the write overhead, and serve multi-column queries worse.
-- Wrong order: the range on placed_at blocks status from being used for seeking
CREATE INDEX bad_idx ON orders (customer_id, placed_at, status);
SELECT * FROM orders
WHERE customer_id = 42
AND placed_at > now() - interval '30 days'
AND status = 'paid';
-- Seeks on customer_id + placed_at range, then filters every row for status
-- Right order: both equalities seek, the range walks, no filtering
CREATE INDEX good_idx ON orders (customer_id, status, placed_at);Skip scan, and why it is not a licence to stop thinking
PostgreSQL 18 added B-tree skip scan, which lets a multicolumn index be used even when an equality condition on a leading column is missing - the planner iterates the distinct values of the leading column and seeks within each. MySQL has had a similar loose index scan for some cases.
It helps most when the leading column has few distinct values. With a leading column of high cardinality, iterating its distinct values approximates scanning the index, and the planner will often prefer a sequential scan anyway. Treat it as a safety net for a query you did not design for, not as a reason to stop ordering columns deliberately.
Why queries still go slow
Here is the diagnostic list. Each of these is an index that exists and is not being used, or is being used and still not helping.
1. A function wraps the indexed column
-- Index on email is useless here: the tree is sorted by email, not lower(email)
SELECT * FROM customers WHERE lower(email) = 'naiem@example.com';
-- Fix: index the expression the query actually uses
CREATE INDEX customers_email_lower_idx ON customers (lower(email));
-- Same problem, different disguise
WHERE date(placed_at) = '2026-08-14' -- not sargable
WHERE placed_at >= '2026-08-14' -- sargable
AND placed_at < '2026-08-15'
WHERE total_cents / 100 > 50 -- not sargable
WHERE total_cents > 5000 -- sargable2. A type mismatch forces an implicit cast
-- customer_id is bigint, the parameter arrives as text
SELECT * FROM orders WHERE customer_id = '42';
-- Postgres usually resolves this cleanly. MySQL comparing a VARCHAR column
-- to a number converts the COLUMN, not the literal, and the index is dropped.
SELECT * FROM users WHERE phone = 8801712345678; -- phone is VARCHAR: full scan::text or CAST appearing around a column in the plan. If the engine casts the column rather than the value, the index cannot be used.3. A leading wildcard
WHERE name LIKE '%rahman' -- no B-tree can help
WHERE name LIKE 'rahman%' -- a prefix is a range; the index works
-- For genuine substring search, use trigrams
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX customers_name_trgm_idx ON customers USING gin (name gin_trgm_ops);
-- now '%rahman%' can use an index4. The index is not selective enough, and the planner is right
If a predicate matches thirty percent of the table, using an index means thirty percent of the rows fetched as random reads, plus the index traversal. A sequential scan reads the same pages in order and is genuinely faster. The planner choosing a sequential scan here is correct, and adding another index will not change it.
-- 40% of orders are 'paid'. An index on status alone will be ignored.
SELECT * FROM orders WHERE status = 'paid';
-- But this is selective, and a partial index is very effective
SELECT * FROM orders WHERE status = 'refund_pending';
CREATE INDEX orders_refund_pending_idx ON orders (placed_at)
WHERE status = 'refund_pending';5. The statistics are stale or wrong
The planner decides using estimated row counts. If those estimates are wrong, it makes reasonable decisions from bad information - choosing a nested loop for what turns out to be a million rows, or a sequential scan for what turns out to be six.
ANALYZE orders; -- refresh statistics
-- Increase the sample for a column with a skewed distribution
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;
-- Correlated columns: the planner assumes independence and multiplies,
-- badly underestimating when the columns move together
CREATE STATISTICS orders_city_postcode (dependencies, ndistinct)
ON city, postcode FROM orders;
ANALYZE orders;6. OR across different columns
-- Often cannot use one index efficiently
SELECT * FROM orders WHERE customer_id = 42 OR reference = 'REF-99';
-- A union of two indexed lookups usually plans much better
SELECT * FROM orders WHERE customer_id = 42
UNION
SELECT * FROM orders WHERE reference = 'REF-99';7. The sort does not match the index
CREATE INDEX orders_placed_idx ON orders (placed_at DESC);
-- Uses the index order: no sort node in the plan
SELECT * FROM orders ORDER BY placed_at DESC LIMIT 20;
-- Mixed directions: the single-column index no longer provides the order
SELECT * FROM orders ORDER BY customer_id ASC, placed_at DESC LIMIT 20;
-- Match the index to the sort exactly, including direction and null placement
CREATE INDEX orders_cust_placed_idx
ON orders (customer_id ASC, placed_at DESC NULLS LAST);Sort node above an index scan with a LIMIT is a strong signal. The engine is fetching everything, sorting it, and discarding most of it - the index could have delivered the rows already ordered.8. OFFSET on deep pages
-- Page 5,000: the engine walks and discards 100,000 rows to return 20
SELECT * FROM orders ORDER BY placed_at DESC LIMIT 20 OFFSET 100000;
-- Keyset pagination: constant time at any depth
SELECT * FROM orders
WHERE (placed_at, id) < ($1, $2)
ORDER BY placed_at DESC, id DESC
LIMIT 20;OFFSET is the problem, and no index fixes it - the tiebreaker column keeps the ordering deterministic across pages.9. The index exists but the table needs a vacuum
In PostgreSQL, an index-only scan still has to confirm each row is visible to your transaction. It checks the visibility map first; if the map is stale because autovacuum has fallen behind, it falls back to the heap for every row and the index-only scan stops being index-only.
-- In EXPLAIN ANALYZE output:
-- Index Only Scan using orders_cust_idx (actual rows=1000 …)
-- Heap Fetches: 998 ← the visibility map is stale
VACUUM (ANALYZE) orders;
-- Watch for tables autovacuum is not keeping up with
SELECT relname, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC LIMIT 10;10. Index bloat
Under heavy update and delete churn, B-tree pages accumulate dead entries and split unevenly. The index grows, fits less of itself in cache, and takes more reads per lookup. Rebuilding compacts it.
-- Rebuild without blocking writes
REINDEX INDEX CONCURRENTLY orders_cust_placed_idx;
-- Compare index size against the table
SELECT indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS size,
idx_scan
FROM pg_stat_user_indexes
WHERE relname = 'orders'
ORDER BY pg_relation_size(indexrelid) DESC;11. Too many indexes
This is the one that creeps up. Every index must be updated on every insert, and on every update touching its columns. Eleven indexes means eleven B-trees to maintain per write, eleven structures competing for cache, and a planner with more options to evaluate. Beyond that, unused indexes make bulk loads and migrations slower for no benefit at all.
-- Never scanned since statistics were last reset: candidates for removal
SELECT relname, indexrelname, idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelid NOT IN (SELECT conindid FROM pg_constraint)
ORDER BY pg_relation_size(indexrelid) DESC;
-- Duplicates: an index on (a) is redundant if (a, b) exists
-- Check before dropping - a narrower index can still be worth keeping
-- if it is much smaller and heavily used.idx_scan = 0 across a full business cycle, not a quiet Tuesday. A monthly report's index legitimately shows zero scans for four weeks.12. The plan is right and the query is wrong
Sometimes the index is perfect and the query is asking for too much. Selecting every column when you need three prevents an index-only scan. Joining five tables to compute one count does work a materialised view could have done overnight. SELECT * in an ORM's default fetch is a very common version of this.
Covering indexes and index-only scans
If every column a query touches is present in the index, the engine never needs the table. In PostgreSQL that is an Index Only Scan; in MySQL the plan shows Using index. This is the largest single win available on a hot read path.
-- The query
SELECT customer_id, total_cents
FROM orders
WHERE customer_id = 42 AND status = 'paid';
-- Key columns for seeking, INCLUDE columns just along for the ride
CREATE INDEX orders_cust_status_idx
ON orders (customer_id, status) INCLUDE (total_cents);
-- MySQL has no INCLUDE; put the extra column in the key instead
CREATE INDEX orders_cust_status_idx
ON orders (customer_id, status, total_cents);INCLUDE columns live only in the leaves, so they do not enlarge the internal pages or affect sort order - which means a covering index stays cheaper than adding the column to the key.- Only cover queries that justify it. Every included column enlarges the index and the write cost. Cover the endpoint called ten thousand times a minute, not the admin report.
- Watch
Heap Fetches. An index-only scan doing heap fetches for most rows is not delivering the benefit - that is a vacuum problem, not an index problem. - In InnoDB the primary key is free. It is already in every secondary index leaf, so a query selecting only indexed columns plus the primary key is already covered.
Reading a plan
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT o.id, o.total_cents, c.full_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.placed_at > now() - interval '7 days'
ORDER BY o.placed_at DESC
LIMIT 50;ANALYZE runs the query and reports what actually happened. Wrap it in a transaction you roll back if the statement writes.| What you see | What it means |
|---|---|
Seq Scan on a large table with a selective filter | Missing index, or statistics so wrong the planner thinks it is unselective |
rows= far from actual rows= | Bad estimate. Fix this before anything else |
Sort above a scan, under a LIMIT | The index could have supplied the order; match its columns and direction |
Heap Fetches high on an Index Only Scan | Stale visibility map - the table needs vacuuming |
Filter: removing most rows after the scan | The predicate could not use the index. Expression or partial index may fix it |
external merge Disk in a Sort | The sort spilled. Raise work_mem for that query |
Nested Loop with a large inner row count | Usually a bad estimate that should have been a hash join |
Bitmap Heap Scan with high Rows Removed by Index Recheck | The bitmap went lossy - work_mem again |
Rows Removed by Filter in the thousands | You are reading far more than you return |
Paste long plans into explain.dalibo.com or explain.depesz.com. Both render the tree and highlight where the time and the misestimates are, which is far quicker than counting indentation by eye.
When a B-tree is the wrong structure
| Type | Use it for |
|---|---|
| B-tree | Equality, ranges, sorting. The default, and correct nearly always |
| GIN | Values containing many items - jsonb containment, arrays, full-text search |
| GiST | Ranges, geometric data, nearest-neighbour, exclusion constraints |
| BRIN | Huge append-only tables with natural ordering. A tiny index over a vast table |
| Hash | Equality only. Rarely beats a B-tree enough to be worth the loss of range support |
| HNSW (pgvector) | Vector similarity search |
-- BRIN on a large append-only log: kilobytes of index for gigabytes of table
CREATE INDEX events_time_brin_idx ON events USING brin (created_at);
-- GIN for jsonb containment
CREATE INDEX orders_meta_idx ON orders USING gin (metadata);
SELECT * FROM orders WHERE metadata @> '{"channel": "mobile"}';The cost side
Indexes are not free, and the cost is paid on every write, forever, whether the index is used or not.
- Inserts update every index on the table. Ten indexes means ten B-tree insertions per row.
- Updates update every index whose columns changed. In PostgreSQL, an update that cannot use HOT optimisation writes new entries in *all* indexes, not just the affected ones.
- Deletes leave dead entries behind until vacuum reclaims them.
- Bulk loads are dramatically faster with indexes dropped and rebuilt afterwards - often several times faster on a large import.
- Storage and cache. Indexes can easily exceed the size of the table they index, and they compete with table data for the same buffer pool.
-- Always build indexes concurrently on a live table
CREATE INDEX CONCURRENTLY orders_new_idx ON orders (customer_id, placed_at DESC);
-- Test whether an index would help BEFORE paying to build it
CREATE EXTENSION IF NOT EXISTS hypopg;
SELECT * FROM hypopg_create_index(
'CREATE INDEX ON orders (customer_id, status, placed_at)');
EXPLAIN SELECT … ; -- the planner considers the hypothetical indexCREATE INDEX without CONCURRENTLY locks the table against writes for the whole build - an outage on any large production table. hypopg lets you evaluate an index in seconds instead of hours.A workflow that actually finds things
- Find the expensive queries, by total time.
pg_stat_statementsordered bytotal_exec_time, not mean. A 5ms query called two million times costs more than a 3-second report. - Run
EXPLAIN (ANALYZE, BUFFERS)on the worst one. Read the estimate-versus-actual gap before looking at anything else. - Fix the estimate if it is wrong.
ANALYZE, raise the statistics target, or add extended statistics for correlated columns. - Check sargability. Is a function, a cast or a leading wildcard preventing index use?
- Design one index for the query shape, not one per column: equality columns, then the range column, then anything worth
INCLUDE. - Test it hypothetically, build it concurrently, and measure the same query again.
- Then look for what you can remove. Every new index is a good moment to drop one that has never been scanned.
Mistakes that keep recurring
| Mistake | Consequence |
|---|---|
One index per column in the WHERE clause | Write overhead multiplied, and multi-column queries still slow |
| Wrong column order in a composite index | The index is ignored, or seeks on only the first column |
| Range column before equality columns | Everything after the range is filtered, not sought |
| Functions wrapped around indexed columns | The index cannot be used at all |
| No index on foreign key columns | Slow joins, and slow cascading deletes - PostgreSQL indexes the referenced side, not the referencing one |
CREATE INDEX without CONCURRENTLY in production | The table is locked against writes for the entire build |
| Random UUID primary keys | Page splits, fragmentation, and in InnoDB a bloated copy in every secondary index |
| Never dropping unused indexes | Permanent write tax for zero benefit |
| Indexing a low-cardinality column alone | Never used, because a sequential scan is genuinely cheaper |
| Adding indexes without reading the plan | Guessing. Sometimes right, and never repeatable |
Ignoring Heap Fetches on index-only scans | A vacuum problem misdiagnosed as an index problem |
OFFSET pagination on large tables | Linearly slower with depth, and no index will fix it |
Verdict
Indexing stops being guesswork the moment you hold two facts in your head. First, a B-tree is sorted, so it can answer questions about the value in the order it is sorted and nothing else. Second, the planner is choosing from estimates, so when it makes an apparently stupid decision it is usually working from bad information rather than being stupid.
Almost every "the index isn't being used" case reduces to one of those. The predicate is not in a form the sort order can serve - a function, a cast, a leading wildcard, a skipped leading column. Or the estimate is wrong and a sequential scan looked cheaper than it was.
Design one index per query shape, not one per column. Read the plan before you create it and after. And every time you add one, look for one to drop.
If you take three things: put equality columns before range columns in composite indexes, keep the indexed column bare on one side of every comparison, and compare estimated against actual rows before touching anything. Those three cover the large majority of slow queries that already have an index on them.
Sources
- Use The Index, Luke - the clearest explanation of SQL indexing anywhere, engine-neutral and free
- PostgreSQL indexes documentation - types, expression indexes, partial indexes, index-only scans
- PostgreSQL
EXPLAIN- how to read a plan, with worked examples - InnoDB clustered and secondary indexes - why primary key length matters in MySQL
- MySQL optimisation and indexes - the engine's own guidance
- explain.dalibo.com - plan visualisation that highlights misestimates



