Indexes: B-tree, GIN, GiST, partial and expression
Choosing the right index type, composite column order, covering indexes with INCLUDE, partial and expression indexes, and when to reindex.
Which index type
| Type | Good for | Operators |
|---|---|---|
btree | Equality, ranges, sorting, default choice | = < <= > >= between in, like 'x%' |
gin | jsonb, arrays, tsvector, trigrams | @> ? ?& @@ % |
gist | Geometry, ranges, exclusion constraints | && <@ @> |
brin | Very large append-only tables | Ranges on naturally ordered columns |
hash | Equality only | =; rarely better than btree |
-- composite: the order of columns is the access rule
-- supports (customer_id), (customer_id, issued_at) but NOT (issued_at) alone
create index invoice_customer_issued on invoice (customer_id, issued_at desc);
-- covering: all columns the query needs, so no heap fetch
create index invoice_customer_issued_cover on invoice (customer_id, issued_at desc)
include (total_cents, status);
-- partial: index only the rows the hot query reads
create index invoice_unpaid on invoice (due_on)
where status = 'issued';
-- expression: index the computed value
create index invoice_month on invoice (date_trunc('month', issued_at));
create index customer_email_lower on customer (lower(email));Only an index whose leading columns appear in the query can be used. A composite index on (customer_id, issued_at) is useless to a query filtering only on issued_at, which is why column order is the single decision that matters most.
Indexes in practice
-- build without blocking writes
create index concurrently invoice_status_idx on invoice (status);
-- an index on an expression must match the expression literally
-- this query cannot use the index above:
select * from invoice where issued_at::date = current_date;
-- this one can:
select * from invoice where issued_at >= current_date
and issued_at < current_date + 1;
-- find unused indexes (they cost writes and space for nothing)
select relname as table_name, indexrelname as index_name, idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) as size
from pg_stat_user_indexes
where idx_scan = 0 and indexrelid not in (select conindid from pg_constraint)
order by pg_relation_size(indexrelid) desc;
-- find missing indexes: sequential scans on large tables
select relname, seq_scan, seq_tup_read, idx_scan
from pg_stat_user_tables where seq_scan > 100 order by seq_tup_read desc;create index concurrentlytakes longer, does two table scans and can fail leaving an invalid index. Checkpg_index.indisvalidand drop and retry if it did.- Every index adds write cost and bloat. Adding twenty indexes to make one report fast is the classic own goal.
- The planner needs current statistics. If a query suddenly picks a bad plan,
analyzethe table before rewriting the SQL. reindex concurrentlyrebuilds bloat; plainreindextakes an exclusive lock on the table.
💡
A partial index is often the highest-value index you can add: if a queue table is 99 percent processed rows, indexing only the pending ones makes the index a fraction of the size and keeps it in cache. Just make sure the application query includes the same predicate, or the planner will not match it.
FAQ
Why is my index not used?
Four common reasons: the leading column is not in the predicate, the table is small enough that a sequential scan is genuinely cheaper, the types differ (
text compared with bigint forces a cast), or statistics are stale. Read EXPLAIN (ANALYZE, BUFFERS) before guessing.Should every foreign key have an index?
Yes for the referencing column. PostgreSQL indexes the referenced key automatically but not the referencing side, so a delete on the parent scans the child table without one.
Related
Query planning and performance tuning Data types, tables and constraints
Last refreshed 2026-09-18.