Book a 30-min callBook a call

Database indexing explained: B-trees, composite and covering indexes, and reading a query plan

How B-tree indexes work, what they cost, how to order composite indexes, when covering and partial indexes help, and how to read a PostgreSQL query plan.

A tree of boxes from one root through branches to a row of leaves, with one highlighted path down to a single table row.
Written by
BYD Tech
Published
Topic
Deep dives
Reading time
15 min read

Questions people ask

Why is PostgreSQL not using my index?

Usually for one of four reasons: the query matches too many rows for the index to be cheaper than a sequential scan, the statistics are stale (run ANALYZE), the query wraps the column in a function or cast, or the condition is not on a leading column of a composite index. Compare estimated and actual rows in EXPLAIN ANALYZE to tell them apart.

Does column order matter in a composite index?

Yes. Only a leading prefix of the columns narrows the scan: equality conditions on the first columns plus a range on the next one. Put equality columns first and the range or sort column after them. An index on (a, b) also serves queries on a alone, but usually not queries on b alone.

Should I index foreign key columns?

Usually, yes. PostgreSQL creates indexes for primary keys and unique constraints, but not for the column that references another table. Without one, joins on that column and deletes or key updates on the parent table have to scan the child table. MySQL InnoDB requires an index on foreign key columns and creates one if none exists.

How many indexes are too many?

There is no fixed number. Every index must earn its write and storage overhead by serving queries you actually run. Remove indexes that pg_stat_user_indexes shows are never scanned and indexes that are a leading prefix of another, and be stricter on tables with heavy writes.

What is the difference between EXPLAIN and EXPLAIN ANALYZE?

EXPLAIN shows the chosen plan with estimated costs and row counts without running the query. EXPLAIN ANALYZE runs it and adds actual times, row counts and loops, and in PostgreSQL 18 buffer counts too. Because it executes the statement, wrap data-changing queries in a transaction you roll back.

Building something like this?