SQL

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

Given — already loaded, nothing to run heresql
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;
What the planner decides on a tiny tablepostgres-16
SELECT count(*) AS georgian_customersFROM customersWHERE country = 'GE';
What to look for

Try it yourself

2 visible tests · 2 hidden tests

Table 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.

Given — already loaded, nothing to run heresql
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');
Loading editor…

Sign up to check the hidden tests and save your progress. Sign up