Tier 1 · Data

Indexing

Why you added an index and the query got no faster

⏱ 18 min dataperformancedatabases

What an index is

A second data structure that maps values to row locations, kept sorted (or hashed) so lookups don't scan. The table is the truth; the index is a shortcut that must be maintained on every write.

That last clause is the cost, and it's the reason "just index everything" is wrong. Each index means additional writes, additional WAL, additional memory, and more work for the planner.

Clustered vs secondary

Clustered index — the table's rows are physically stored in index order. There can be only one, because the data can only be laid out one way. InnoDB clusters by primary key; looking up by PK finds the row itself with no extra hop.

Secondary index — stores the indexed value plus a pointer to the row. In InnoDB that pointer is the primary key, so a secondary lookup is two traversals: find the PK in the secondary index, then walk the clustered index to get the row.

Two practical consequences:

Postgres differs: it stores rows in an unordered heap, and all indexes point to physical row locations. No clustered index, so no PK-size amplification — but also no free ordering.

Composite indexes and the leftmost prefix

An index on (country, city, created_at) is sorted by country first, then city within country, then time within city. It can serve:

It cannot efficiently serve WHERE city = 'Zurich' alone. Without the leading column there's no contiguous range to scan — like looking up a surname in a phone book sorted by first name.

The ordering rule that follows: equality columns first, then the range/sort column last. An index on (status, created_at) serves WHERE status='open' ORDER BY created_at perfectly, because rows for one status are already in time order. Reverse the columns and the database must sort.

Covering indexes

If an index contains every column a query needs, the database can answer from the index alone and never touch the table — an index-only scan. For a query like SELECT user_id, total FROM orders WHERE status = 'open', an index on (status, user_id, total) avoids all row lookups.

This is one of the largest wins available, especially in InnoDB where it eliminates the second traversal. The cost is a wider index.

Why the planner ignores your index

This is the hook, and the answers are:

1. Low selectivity. If status = 'active' matches 60% of the table, using the index means random-access lookups for most rows — slower than a sequential scan. The planner is right. Indexes pay off when they eliminate most of the table, roughly under 5–10% selectivity.

2. Stale statistics. The planner estimates row counts from sampled statistics. After a bulk load they can be badly wrong, producing a plan built on a fiction. Run ANALYZE.

3. A function on the column. WHERE LOWER(email) = 'x' cannot use an index on email — the index stores the original values. Fix with an expression index on LOWER(email).

4. An implicit type cast. Comparing a varchar column to a number, or an int to a bigint parameter, can silently disable the index. A frequent and invisible cause.

5. Leading wildcard. LIKE '%foo' has no prefix to seek to. LIKE 'foo%' is fine. For genuine substring search you need a trigram index or a search engine.

6. OR across different columns. WHERE a = 1 OR b = 2 often can't use either index cleanly. A UNION of two indexed queries can be dramatically faster.

Index types beyond B-tree

Type Good for Not for
B-tree Equality, ranges, sorting, prefixes Full-text, containment
Hash Exact equality only Ranges, sorting
GIN / inverted Arrays, JSONB keys, full-text Ranges
GiST Geometric, nearest-neighbour, ranges General equality
BRIN Huge tables where the column correlates with physical order (append-only timestamps) Randomly ordered data
Bitmap Low-cardinality columns in analytics High-churn OLTP

BRIN is worth remembering: on an append-only table of a billion timestamped rows, a BRIN index is kilobytes where a B-tree would be gigabytes, because it stores only min/max per block range. It's the right answer for time-series tables far more often than people realise.

Partial indexes are the other underused tool: CREATE INDEX … WHERE status = 'pending' indexes only the rows you actually query. On a table where 99% of rows are completed and you only ever query pending, the index is 1% of the size and stays hot in memory.

Reading a query plan

You should be able to read this much:

The single most useful habit: compare estimated rows to actual rows (EXPLAIN ANALYZE). A plan predicting 10 rows and getting 4 million chose the wrong strategy for a reason — bad statistics, or a correlation the planner can't see. Fix the estimate, and the plan usually fixes itself.

What to take away

Check yourself

  1. You have an index on (country, city, created_at). Which query can it NOT serve efficiently?

  2. A query filters on status = 'active', which matches 60% of rows. The planner does a sequential scan instead of using your index. Why?

  3. What is an index-only scan, and why is it fast?

  4. You need an index on an append-only table of a billion timestamped rows, and disk space matters. What fits best?