PostgreSQL Cheatsheet

Indexes

Use this PostgreSQL reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

Index types

Six access methods ship in core. Btree is the default and covers almost everything. The rest exist for query shapes btree cannot answer.

TypeUse it whenSupportsNotes
btreeEquality, ranges, sorting, prefix LIKE= < > BETWEEN IN IS NULL, ORDER BYThe default, only one that can serve a sort
hashEquality only, on a wide value=Crash-safe and WAL-logged since 10, rarely beats btree
ginMany keys inside one valuejsonb @> ?, arrays @> &&, full text @@, pg_trgmSlower writes, fastupdate batches them
gistGeometry, ranges, nearest-neighbour&& @> <->, EXCLUDE constraintsLossy, needs a recheck
brinHuge tables where the column correlates with physical orderRanges on append-only timestamps and idsTiny, block-range summaries only
spgistNon-balanced trees, quadtrees, text prefix searchPoints, inet, ranges, text prefixesNiche
CREATE INDEX orders_placed_at_idx     ON orders (placed_at);              -- btree
CREATE INDEX orders_meta_gin          ON orders USING gin (metadata);
CREATE INDEX orders_tags_gin          ON orders USING gin (tags);
CREATE INDEX bookings_during_gist     ON bookings USING gist (during);
CREATE INDEX events_occurred_brin     ON events USING brin (occurred_at);
CREATE INDEX ip_spgist                ON logs USING spgist (client_ip inet_ops);

A brin index on a 500 GB append-only events table can be a few megabytes and still prune 99% of blocks for a date range. It is useless on a randomly-ordered column.

Multicolumn and the leftmost prefix

A multicolumn btree is usable only for a leftmost prefix of its column list. An index on (a, b, c) serves a, (a, b), and (a, b, c), but not b alone or (b, c).

CREATE INDEX o_cust_status_placed ON orders (customer_id, status, placed_at DESC);
Query predicateUses that index?
WHERE customer_id = 7Yes
WHERE customer_id = 7 AND status = 'paid'Yes, best case
WHERE customer_id = 7 ORDER BY placed_at DESCYes, index provides the order
WHERE status = 'paid'No leading column, a full index scan at best
WHERE customer_id = 7 AND placed_at > now() - '7d'::intervalPartly, status gap forces a filter

Column order rules: equality columns first, then the range or sort column last. A DESC or NULLS FIRST in the index only matters when it lets a query skip a sort, and btree can scan backwards, so (a DESC) and (a) both serve ORDER BY a DESC.

Covering, partial, and expression indexes

INCLUDE (11+) stores extra columns in the leaf pages without making them part of the key, which enables an index-only scan without widening the search structure.

-- Index-only scan for: SELECT total FROM orders WHERE customer_id = $1
CREATE INDEX orders_cust_incl ON orders (customer_id) INCLUDE (total);

A partial index only holds rows matching its WHERE. It is smaller, cheaper to maintain, and it is the right tool for a skewed boolean or status column.

CREATE INDEX orders_pending ON orders (placed_at) WHERE status = 'pending';
CREATE INDEX users_active   ON users (email)      WHERE deleted_at IS NULL;

The planner uses a partial index only when it can prove the query predicate implies the index predicate, so query with the literal status = 'pending', not a parameter that is sometimes something else.

An expression index materializes a computed value. The query must use the exact same expression, character for character in effect, for the planner to match it.

CREATE INDEX users_lower_email ON users (lower(email));
SELECT * FROM users WHERE lower(email) = lower($1);      -- matches

CREATE INDEX orders_meta_region
  ON orders ((metadata ->> 'region'));
CREATE INDEX orders_month
  ON orders (date_trunc('month', placed_at AT TIME ZONE 'UTC'));

The function must be IMMUTABLE. now() and random() are not, and neither is anything whose answer depends on the session time zone, which catches the two forms people reach for first:

CREATE INDEX ON orders ((placed_at::date));                 -- ERROR, not IMMUTABLE
CREATE INDEX ON orders (date_trunc('day', placed_at));      -- ERROR, not IMMUTABLE
CREATE INDEX ON orders (date_trunc('day', placed_at AT TIME ZONE 'UTC'));  -- ok
CREATE INDEX ON orders (date_trunc('day', placed_at, 'UTC'));  -- ok, 16+

Pinning the zone explicitly makes the expression immutable. The three-argument date_trunc is 16+.

Text pattern matching

A default btree on text uses the database collation and cannot serve LIKE 'foo%' unless the collation is C. Use an _pattern_ops operator class for prefix search, or pg_trgm for anything with a leading wildcard.

PatternWhat indexes it
LIKE 'foo%'text_pattern_ops btree, or a C-collation column
LIKE '%foo%'gin_trgm_ops GIN (pg_trgm)
ILIKE 'foo%'Expression index on lower(col) with text_pattern_ops
col = 'foo' case-insensitiveExpression index on lower(col), or citext
~ '^foo'text_pattern_ops btree
Full-text @@GIN on to_tsvector(...)
CREATE INDEX users_email_prefix ON users (email text_pattern_ops);
SELECT * FROM users WHERE email LIKE 'ada%';

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX products_name_trgm ON products USING gin (name gin_trgm_ops);
SELECT * FROM products WHERE name ILIKE '%widget%';

CREATE INDEX docs_fts ON docs
  USING gin (to_tsvector('english', title || ' ' || body));
SELECT * FROM docs
WHERE to_tsvector('english', title || ' ' || body) @@ plainto_tsquery('english', $1);

A text_pattern_ops index serves LIKE but not = ordering or ORDER BY, so a column doing both needs two indexes.

Building without downtime

A plain CREATE INDEX takes an ACCESS EXCLUSIVE-adjacent SHARE lock that blocks every write for the whole build. CONCURRENTLY does not.

CREATE INDEX CONCURRENTLY orders_placed_at_idx ON orders (placed_at);
DROP INDEX CONCURRENTLY orders_placed_at_idx;
REINDEX INDEX CONCURRENTLY orders_placed_at_idx;   -- 12+
REINDEX TABLE CONCURRENTLY orders;                -- 12+

The rules that bite:

- CONCURRENTLY cannot run inside a transaction block, on CREATE INDEX, DROP INDEX, or REINDEX alike. Most migration tools wrap everything in one, so this needs an explicit escape hatch. - It scans the table twice and waits for old transactions, so it is slower. - On failure it leaves an invalid index behind that costs writes and answers no query. Find and drop it, then retry.

SELECT indexrelid::regclass AS index, indrelid::regclass AS table
FROM pg_index WHERE NOT indisvalid;

DROP INDEX CONCURRENTLY orders_placed_at_idx;

To replace an index in place, build the new one concurrently under a temporary name, then ALTER INDEX ... RENAME TO inside one transaction after dropping the old.

Auditing what you have

Unused indexes cost every write and every vacuum. pg_stat_user_indexes counts scans since the last stats reset, so check pg_stat_reset time before trusting a zero.

-- Never-scanned indexes, largest first, skipping constraint-backed ones
SELECT s.relname AS table,
       s.indexrelname AS index,
       s.idx_scan,
       pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0
  AND NOT i.indisunique
  AND NOT i.indisprimary
ORDER BY pg_relation_size(s.indexrelid) DESC;
-- Sizes for one table
SELECT pg_size_pretty(pg_relation_size('orders'))       AS heap,
       pg_size_pretty(pg_indexes_size('orders'))        AS indexes,
       pg_size_pretty(pg_total_relation_size('orders')) AS total;

-- Index vs table scan balance
SELECT relname, seq_scan, seq_tup_read, idx_scan, n_live_tup
FROM pg_stat_user_tables ORDER BY seq_tup_read DESC LIMIT 20;

-- Duplicate index definitions
SELECT indrelid::regclass AS table, count(*), array_agg(indexrelid::regclass)
FROM pg_index GROUP BY indrelid, indkey HAVING count(*) > 1;
FunctionReturns
pg_relation_size(rel)Main fork bytes, heap or one index
pg_indexes_size(rel)All indexes on a table
pg_total_relation_size(rel)Heap plus indexes plus TOAST
pg_size_pretty(bigint)12 MB-style text
pg_table_size(rel)Heap plus TOAST, no indexes

Bloat is the gap between live tuples and allocated pages. REINDEX CONCURRENTLY fixes index bloat without an exclusive lock, and VACUUM FULL fixes heap bloat but rewrites the table under ACCESS EXCLUSIVE. See EXPLAIN and Performance.