Indexes

Make the database find rows instead of reading them all — and know what that costs.

18of 22
≈ 14Min read
6Parts

A table is a pile. An index is a contents page

Without an index, "the orders of customer 1" means reading every row and testing it: a scan. With one, the database jumps straight to the right place in a sorted structure and walks a few entries: a search. On twenty-four rows both are instant. On twenty-four million the first one is a page that never loads.

creating one
create index idx_orders_customer on orders (customer_id);

create unique index idx_doors_name on doors (name);     -- a constraint, enforced by the index
drop index idx_orders_customer;

Ask the database what it is doing

Every dialect has this: explain query plan in SQLite, explain analyze in PostgreSQL and MySQL. The words differ, the question does not — scan or search? If a query that should be instant is slow, the plan tells you why in one line, and guessing tells you nothing.

the most useful four words in SQL
explain query plan
select * from orders where customer_id = 1;

-- before:  SCAN orders
-- after:   SEARCH orders USING INDEX idx_orders_customer (customer_id=?)

A B-tree sorts, so it does more than equality

A composite index is sorted by the first column, then the second inside it — like a phone book by surname then first name. You can look up "Aliyev", and "Aliyev, R", but not "everyone called Rashad". The order of the columns is the design decision; equality first, then ranges.

what an index on (status, placed) can answer
where status = 'fitted'                        -- yes
where status = 'fitted' and placed > '2024-06-01'   -- yes
where status = 'fitted' order by placed        -- yes: the order comes free
where placed > '2024-06-01'                    -- no: placed is the second column
where status like 'fit%'                       -- yes: a prefix is a range
where status like '%ted'                       -- no: a leading % has no starting point
A function on the column hides it

where substr(placed, 1, 4) = '2024' is a scan: the index holds placed, not substr(placed). Rewrite as a range — placed >= '2024-01-01' and placed < '2025-01-01' — or build an index on the expression, which PostgreSQL and SQLite both support.

Covering: when the index is the answer

Every column the query needs is already in the index, so the table is never touched. That is the reason select * costs more than naming three columns, and the reason a sensible index can be several times faster than a merely adequate one.

no row has to be read at all
create index idx_orders_status_placed on orders (status, placed);

select status, placed from orders where status = 'fitted' order by placed;
-- SEARCH orders USING COVERING INDEX idx_orders_status_placed (status=?)

What an index costs

Every insert, update and delete has to maintain every index on the table. Ten indexes make writes several times slower and the database several times larger, and an index nobody uses costs exactly as much as one everybody uses.

So: index the columns you filter, join and sort on. Index every foreign key — nothing does it for you outside MySQL, and deletes on the parent scan the child without it. Then look at what the application actually runs and delete the rest.

partial and expression indexes: smaller, cheaper, sharper
-- only the rows anyone queries
create index idx_open_orders on orders (placed) where status <> 'cancelled';

-- an index on what you actually compare
create index idx_doors_lower_name on doors (lower(name));

The ones people get wrong

BeliefReality
"Index every column"each one taxes every write; unused ones are pure cost
"The primary key is enough"it does not help a filter on any other column
"Foreign keys are indexed"only in MySQL/InnoDB — add them yourself elsewhere
"More indexes cannot hurt reads"the planner has more choices and can pick a worse one
"It is slow, add an index"read the plan first; the answer is often a rewrite

Practice