Indexes
Why a query gets slow, what an index actually is, and how to stop one being used by accident.
After this lesson you can
- Say what a btree index stores and what it costs
- Recognise the three common ways a query stops using one
- Read the shape of an execution plan
Without an index, finding the rows where email = 'x' means reading the
whole table. That is a sequential scan, and it is not always wrong: on a
small table, reading everything beats any alternative.
A btree index is a sorted structure that maps a column's values to the rows that hold them. Looking a value up is a handful of page reads instead of a full pass.
CREATE INDEX idx_customers_email ON customers (email);
What it costs
An index is not free. Every insert, update and delete has to maintain it, and it occupies disk and cache that the table itself would otherwise use. A btree over one bigint column on ten million rows is a couple of hundred megabytes. Indexing every column is how a write-heavy table becomes slow.
Three ways to lose an index
A function on the column. The index stores email, not lower(email):
WHERE lower(email) = 'a@b.com' -- cannot use an index on email
Index the expression instead, with CREATE INDEX ... ON customers (lower(email)).
A leading wildcard. LIKE 'ana%' can use a btree, because the index is
sorted and the query is a prefix. LIKE '%ana' cannot: there is no prefix
to seek to.
A type mismatch. Comparing a text column to a number makes the
database cast one side, and a cast is a function.
Composite indexes
An index on (country, created_at) serves a query filtering on country,
and one filtering on country and created_at. It does not serve a
query filtering on created_at alone, for the same reason a phone book
sorted by surname does not help you find everyone called Nino.
Reading a plan
EXPLAIN ANALYZE SELECT * FROM customers WHERE country = 'GE';
Read it inside out. What you want to see is the node at the bottom: an
Index Scan means it used one, a Seq Scan means it read the table. The
numbers you care about are rows estimated against rows actual — when
those diverge by a lot, the planner is working from bad statistics and every
decision above that node is built on a wrong guess.
Try it
CREATE TABLE customers ( id int PRIMARY KEY, name text NOT NULL, country text NOT NULL);INSERT INTO customersSELECT g, 'Customer ' || g, CASE WHEN g % 3 = 0 THEN 'GE' ELSE 'DE' ENDFROM generate_series(1, 50) AS g;CREATE INDEX idx_customers_country ON customers (country);ANALYZE customers;SELECT count(*) AS georgian_customersFROM customersWHERE country = 'GE';Try it yourself
2 visible tests · 2 hidden testsTable customers(id, name, country). Return id and name for every
customer in country = 'GE', ordered by id. Nothing here checks
whether you actually created an index — this sandbox only ever grades
the rows a query returns — but write the equality filter exactly the way
you would if country were indexed: a plain comparison, nothing wrapping
the column.
CREATE TABLE customers ( id int PRIMARY KEY, name text NOT NULL, country text NOT NULL);INSERT INTO customers VALUES (1, 'Nino', 'GE'), (2, 'Hans', 'DE'), (3, 'Ana', 'GE'), (4, 'Piotr', 'PL');Sign up to check the hidden tests and save your progress. Sign up