Overview
Choose the index type from the query shape: B-tree for equality, range, and order; GIN for JSONB, arrays, and full-text; GiST for ranges and nearest-neighbor; BRIN for huge append-only tables. Postgres 18 also ships hash and SP-GiST. The umbrella rules live in postgres.
Use B-tree by default for equality, range, and order
B-tree handles =, <, >, BETWEEN, IN, IS NULL, and ORDER BY. It is what CREATE INDEX builds without a USING clause.
CREATE INDEX orders_user_id_created_at_idx ON orders (user_id, created_at DESC);Put equality columns first, then the range or sort column. Before Postgres 18, an index on (a, b) could not serve a query on b alone. Postgres 18 skip scan can, but it pays off only when a has few distinct values; still build the prefix your queries actually filter on, and read the index-lookup count that EXPLAIN ANALYZE now reports per index scan node.
Use GIN for JSONB, full-text, and arrays
GIN is an inverted index for columns holding many searchable values per row.
- JSONB: the default
jsonb_opsclass supports?,?|,?&,@>,@?, and@@.jsonb_path_opsdrops the key-existence operators and in return is smaller and faster. See postgres-jsonb. - Full-text: index a stored
tsvectorcolumn; see postgres-full-text-search. - Arrays:
CREATE INDEX posts_tags_gin ON posts USING GIN (tags);supports&&and@>.
GIN is slow to build and update. Postgres 18 can build GIN indexes in parallel; bulk loads are still faster with the index dropped and recreated afterwards.
Use GiST for ranges, spatial data, and exclusion constraints
GiST supports overlap and nearest-neighbor queries on ranges and geometries.
CREATE EXTENSION IF NOT EXISTS btree_gist; -- needed for "room_id WITH =" in GiST
ALTER TABLE bookings
ADD CONSTRAINT no_overlap EXCLUDE USING GiST (room_id WITH =, during WITH &&);GiST also backs PostGIS and pg_trgm similarity search; see postgres-full-text-search.
Use BRIN only when row order matches the column
BRIN stores one summary per block range, so a terabyte table needs kilobytes of index. It works only when physical row order correlates with the indexed column, such as append-only timestamps. On randomly ordered data it degrades to a sequential scan.
CREATE INDEX events_created_at_brin ON events USING BRIN (created_at) WITH (pages_per_range = 32);The default is 128 pages per range; smaller ranges give finer pruning and a larger index. Pair BRIN with postgres-partitioning so inserts stay in order.
Add partial, expression, and covering indexes for specific queries
- Partial: exclude the rows you never query.
CREATE INDEX users_active_email_idx ON users (email) WHERE deleted_at IS NULL;The planner uses it only when the query predicate implies the index predicate. - Expression: index what the query filters on.
CREATE INDEX users_lower_email_idx ON users (lower(email));matchesWHERE lower(email) = lower($1)and nothing that writes the expression differently. - Covering:
INCLUDEadds non-key columns so index-only scans skip the heap.INCLUDEcolumns do not affect ordering or uniqueness. Use it on hot read paths, not on every index.
Build and drop indexes CONCURRENTLY on live tables
A plain CREATE INDEX blocks writes to the table (reads continue) until the build finishes. CONCURRENTLY builds without blocking inserts, updates, or deletes.
CREATE INDEX CONCURRENTLY orders_user_id_idx ON orders (user_id);
DROP INDEX CONCURRENTLY orders_old_idx;CREATE INDEX CONCURRENTLY cannot run inside a transaction block, so give it its own migration; see migrations and prisma-migrations. A failed build leaves an INVALID index that still costs write overhead. Drop it and retry, or run REINDEX INDEX CONCURRENTLY.
Drop indexes you do not use
Every index costs write amplification, vacuum work, and storage. Prefer one composite index that serves several queries over many single-column indexes. Drop indexes with zero scans in pg_stat_user_indexes after a full business cycle, and check every replica first because the counters are per server. Validate each new index with postgres-explain; the end-to-end loop is in debug-postgres-slow-query.