Indexes: B-tree, multicolumn, partial, expression and GIN
Which index a query can use and why: leftmost prefixes, sorted reads without a Sort, partial and expression indexes, and GIN for JSONB and arrays.
After this lesson you can
- Predict whether a multicolumn B-tree index serves a given WHERE and ORDER BY
- Explain index scan, bitmap scan and sequential scan and when each wins
- Design a partial or expression index for a specific query
- Use a GIN index for JSONB containment and array membership
A B-tree index keeps a column's values (or a tuple of columns) sorted, each pointing at a row's location in the table (the heap). Finding a value is a seek: a few page reads down the tree, then along the sorted leaf pages for as long as values match. A sequential scan reads every page of the table instead. Neither is always better: the planner estimates how many rows match and picks the cheaper plan.
Three ways to read a table
Index Scan— walk the index and fetch each matching row from the heap in index order. Good for few rows, and forORDER BY ... LIMITbecause rows arrive sorted.Bitmap Index Scan+Bitmap Heap Scan— collect all matching row locations first, then read the heap pages in physical order, each once. Good for a moderate number of rows, or for combining two indexes.Seq Scan— read everything. Wins when a large share of rows match.
The examples use a helper, plan_of(query), defined in their setup: it
runs EXPLAIN (COSTS OFF) with parallel workers disabled (so plans stay
short) and joins the plan lines with |. The table has 200,000 orders
and three indexes: (customer_id, created_at), a partial index on
created_at for open orders only, and one on lower(email).
CREATE FUNCTION plan_of(q text) RETURNS text LANGUAGE plpgsql SET max_parallel_workers_per_gather = 0 AS $$DECLARE line text; result text := '';BEGIN FOR line IN EXECUTE 'EXPLAIN (COSTS OFF) ' || q LOOP result := result || CASE WHEN result = '' THEN '' ELSE ' | ' END || trim(line); END LOOP; RETURN result;END $$;CREATE TABLE orders ( id int PRIMARY KEY, customer_id int NOT NULL, status text NOT NULL, created_at timestamp NOT NULL, email text NOT NULL);INSERT INTO ordersSELECT g, g % 5000, CASE WHEN g % 50 = 0 THEN 'open' ELSE 'done' END, timestamp '2024-01-01' + g * interval '1 minute', 'User' || (g % 5000) || '@Example.com'FROM generate_series(1, 200000) AS g;CREATE INDEX orders_customer_created ON orders (customer_id, created_at);CREATE INDEX orders_open_created ON orders (created_at) WHERE status = 'open';CREATE INDEX orders_email_lower ON orders (lower(email));ANALYZE orders;SELECT label, plan_of(q) AS planFROM (VALUES (1, 'both columns', $$SELECT * FROM orders WHERE customer_id = 42 AND created_at >= '2024-03-01'$$), (2, 'leading column + ORDER BY', $$SELECT * FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 3$$), (3, 'second column only', $$SELECT * FROM orders WHERE created_at >= '2024-03-01' AND created_at < '2024-03-01 02:00'$$), (4, 'most rows match', $$SELECT * FROM orders WHERE status = 'done'$$), (5, 'partial: open', $$SELECT id FROM orders WHERE status = 'open' AND created_at >= '2024-04-01' ORDER BY created_at LIMIT 10$$), (6, 'partial: done', $$SELECT id FROM orders WHERE status = 'done' AND created_at >= '2024-04-01' ORDER BY created_at LIMIT 10$$), (7, 'expression', $$SELECT id FROM orders WHERE lower(email) = 'user42@example.com'$$), (8, 'raw column', $$SELECT id FROM orders WHERE email = 'User42@Example.com'$$)) AS t(n, label, q)ORDER BY n;Reading the results
- Both columns and leading column + ORDER BY use the multicolumn
index. In the second, the index is read backwards and the
Limitstops after three rows: noSortnode at all, because the index already holds each customer's orders increated_atorder. - Second column only cannot seek: the index is sorted by
customer_idfirst, so orders from a given minute are scattered across it. This is the leftmost-prefix rule: an index on(a, b, c)can seek ona,a, bora, b, c, not onborcalone. - Most rows match: 98% of orders are
done, so reading the whole table is cheaper than 196,000 index lookups. - Partial index:
WHERE status = 'open'matches the index's own predicate, so the small index (2% of the rows) is usable; the same query fordonecannot use it. - Expression index: an index on
lower(email)serves only predicates written on ; a plain cannot use it.
CREATE FUNCTION plan_of(q text) RETURNS text LANGUAGE plpgsql SET max_parallel_workers_per_gather = 0 AS $$DECLARE line text; result text := '';BEGIN FOR line IN EXECUTE 'EXPLAIN (COSTS OFF) ' || q LOOP result := result || CASE WHEN result = '' THEN '' ELSE ' | ' END || trim(line); END LOOP; RETURN result;END $$;CREATE TABLE events (id int PRIMARY KEY, payload jsonb NOT NULL, tags text[] NOT NULL);INSERT INTO eventsSELECT g, jsonb_build_object('type', CASE WHEN g % 100 = 0 THEN 'refund' ELSE 'view' END, 'user', g % 5000), CASE WHEN g % 200 = 0 THEN ARRAY['vip', 'eu'] ELSE ARRAY['eu'] ENDFROM generate_series(1, 100000) AS g;CREATE INDEX events_payload_gin ON events USING gin (payload);CREATE INDEX events_tags_gin ON events USING gin (tags);ANALYZE events;SELECT label, plan_of(q) AS planFROM (VALUES (1, 'containment @>', $$SELECT count(*) FROM events WHERE payload @> '{"type": "refund"}'$$), (2, 'extracted text ->>', $$SELECT count(*) FROM events WHERE payload ->> 'type' = 'refund'$$), (3, 'array contains @>', $$SELECT count(*) FROM events WHERE tags @> ARRAY['vip']$$)) AS t(n, label, q)ORDER BY n;The cost side
Every index gets an entry for every INSERT, and for most UPDATEs: only
an update that changes no indexed column and finds room on the same page
(a HOT update) can skip them. More indexes mean slower writes, more disk
and more cache pressure. Index the queries you
actually run, and drop indexes nothing uses
(pg_stat_user_indexes.idx_scan stays at 0), after checking the index
does not back a primary key or unique constraint, which does its job
without ever being scanned by a query.
Common mistakes
- Creating
(created_at, customer_id)for a query that filters oncustomer_idwith a range oncreated_at. Put equality columns first, the range column last. - Expecting a partial index to be used by a query whose
WHEREdoes not imply the index predicate. - Indexing
emailand queryinglower(email), or the reverse. - Adding a GIN index and filtering with
payload ->> 'type' = ..., which it cannot serve, as theginexample shows.
What interviewers ask
- Index on (a, b): which queries can seek with it?
a,aandb,awith ; a condition on alone cannot seek (at most the whole index is read). Explain with the sorted-order picture.
Exercise · Pro
Which indexes make the Sort disappear?
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade