43 CREATE INDEX
An index is an auxiliary data structure that lets PostgreSQL find rows by a column value without scanning the whole table. A primary key automatically creates one. For other columns, you choose: CREATE INDEX idx ON table (col). The default and most useful index type is B-tree, which speeds up equality (=), range (<, >, BETWEEN), and prefix (LIKE 'abc%') lookups, plus ORDER BY. PostgreSQL also offers GIN (for full-text, arrays, jsonb), GiST (for ranges and geometry), BRIN (for huge, naturally-ordered tables), Hash, and SP-GiST.
What you’ll learn
- Creating a B-tree index on a column or set of columns
- Naming and listing indexes
- Multi-column index order and what it implies
- Partial and expression indexes
CREATE INDEX CONCURRENTLYfor production
Example
A B-tree index is a sorted, balanced tree that supports =, <, >, BETWEEN, IS NULL, ORDER BY, and LIKE 'prefix%' (anchored prefix). It is the default and what 90% of indexes in a typical schema are.
Multi-column indexes follow the leftmost prefix rule: an index on (a, b, c) can serve queries that filter on a, on (a, b), on (a, b, c), and can also scan for predicates on b alone or (b, c), though without leading-column restrictions this may require much more of the index. Choose column order from the actual filters and ordering requirements. PostgreSQL 18 adds skip-scan optimization for some of these cases.
Partial indexes index only the rows where a predicate is true. Excellent for “is_active = true” style queries — the index is smaller, faster, and avoids covering most of the (uninteresting) rows. Expression indexes let you index a transformed value (lower(email), (data ->> 'name')). They are the only way to make WHERE lower(email) = 'x' index-fast.
CREATE INDEX CONCURRENTLY builds the index without taking a table-level write lock. It takes longer (two passes over the table) but does not block writes — required in production.
Run the example
To run (requires earlier tables):
Common pitfalls
- Over-indexing: inserts and many updates create index entries; HOT updates can avoid index work, while cleanup of dead index entries is usually deferred to vacuum. On write-heavy tables, indexes you do not actually use are pure overhead.
- Indexing low-cardinality columns alone (e.g.
boolean is_active) usually does not help — the planner will sequential-scan anyway. Combine with another column or use a partial index. - Naming indexes inconsistently makes debugging painful. Adopt a convention like
tablename_column_idx. CREATE INDEXwithoutCONCURRENTLYtakes aSHARElock: ordinary reads continue, but writes wait. UseCONCURRENTLYwhen writes must continue; it cannot run inside a transaction block and has extra work and failure-handling requirements.
pg_stat_user_indexes shows how often each index is used (idx_scan). Periodically check it; an index with zero scans over weeks is a candidate for dropping.
Create an index on customer_id only and another on (customer_id, total). Look at EXPLAIN SELECT * FROM orders_example WHERE customer_id = 1 ORDER BY total; and see which one the planner picks. Drop the unused one.
Downloads
SQL file: create-index.sql · All lesson files · Setup guide