PostgreSQL indexes and EXPLAIN
Without an index, finding rows means reading the whole table (a sequential scan). An index is a separate, sorted structure that lets PostgreSQL jump to matching rows, much like the index at the back of a book. Indexes speed up reads but cost disk space and slow down writes a little, so add them for real query patterns.
- B-tree (default): equality and ranges (
=,<,BETWEEN,ORDER BY,LIKE 'abc%'). - GIN: values with many parts: jsonb, arrays, full-text.
- GiST / SP-GiST / BRIN / hash: geometry and ranges, huge naturally ordered tables, and pure equality.
- Multi-column
(a, b)helps filters on a, or on a and b, but not on b alone. - Partial (
WHERE status = 'pending') indexes only some rows. Expression indexes, likelower(email), index a computed value. Unique indexes enforce uniqueness.
EXPLAIN shows the plan the optimizer picked; EXPLAIN ANALYZE actually runs the query and adds real timings and row counts. Read plans from the most indented line outwards. On tiny tables PostgreSQL often prefers a sequential scan even when an index exists, because it’s genuinely cheaper, so the examples build a bigger table.
Foreign key columns are not indexed automatically. Indexing them (e.g. orders.customer_id) is one of the most common fixes for slow joins.
Syntax
CREATE INDEX [CONCURRENTLY] name ON table (col); CREATE INDEX name ON table USING gin (jsonb_col); EXPLAIN ANALYZE SELECT ...;
Examples
Edit the code and run it. Output is printed the way psql prints it.
23.1Build a table with 100,000 rows
DROP TABLE IF EXISTS events;
CREATE TABLE events AS
SELECT g AS id,
(random() * 5000)::int AS user_id,
(ARRAY['view','click','buy','signup'])[1 + (random() * 3)::int] AS kind,
timestamp '2024-01-01' + (random() * 365) * interval '1 day' AS created_at,
jsonb_build_object('device', (ARRAY['ios','android','web'])[1 + (random()*2)::int]) AS meta
FROM generate_series(1, 100000) AS g;
ANALYZE events;
SELECT count(*) FROM events;CREATE TABLE ... AS SELECT creates and fills a table in one go. generate_series produces 100,000 rows of random data. ANALYZE collects the statistics the planner uses to estimate costs.
23.2Before the index: sequential scan
EXPLAIN ANALYZE
SELECT * FROM events WHERE user_id = 42;Look for Seq Scan on events and “Rows Removed by Filter”: PostgreSQL read every row to find a handful. Note the execution time.
23.3After the index: index scan
CREATE INDEX IF NOT EXISTS events_user_idx ON events (user_id);
EXPLAIN ANALYZE
SELECT * FROM events WHERE user_id = 42;Now the plan shows a Bitmap Index Scan or Index Scan using events_user_idx, touching only matching rows, and the time drops sharply.
23.4Multi-column, partial and expression indexes
CREATE INDEX IF NOT EXISTS events_user_time_idx ON events (user_id, created_at);
CREATE INDEX IF NOT EXISTS events_buy_idx ON events (created_at) WHERE kind = 'buy';
CREATE INDEX IF NOT EXISTS events_device_idx ON events ((meta ->> 'device'));
EXPLAIN SELECT * FROM events
WHERE kind = 'buy' AND created_at >= '2024-12-01';
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'events';The partial index only contains purchases, so it is small and exactly fits “recent purchases” queries. pg_indexes lists every index on a table.
23.5GIN index for jsonb
CREATE INDEX IF NOT EXISTS events_meta_gin ON events USING gin (meta);
EXPLAIN ANALYZE
SELECT count(*) FROM events WHERE meta @> '{"device": "ios"}';A GIN index supports @>, ? and friends. Because about a quarter of rows match here, the planner may still choose a sequential scan; indexes win when a query selects a small fraction of the table. Try '{"device": "tv"}' instead.
Exercises
Write a query and check it. Any query that returns the right rows passes.
Exercise 23.1
orders(customer_id) (any name).Hint
CREATE INDEX orders_customer_idx ON orders (customer_id);
Solution
CREATE INDEX orders_customer_idx ON orders (customer_id);