PostgreSQL Field Guide
Core knowledgeIndexes and EXPLAIN

Indexes and EXPLAIN

Verify index value with plans, actual timing, and buffer access

Capture the plan first

EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT id, customer_id, placed_at
FROM orders
WHERE customer_id = 42
ORDER BY placed_at DESC
LIMIT 20;
  • EXPLAIN shows estimates without executing.
  • ANALYZE executes and reports actual rows and timing. Wrap writes in a transaction and roll back.
  • BUFFERS reports shared/local/temp block hits and reads.
  • Compare estimated rows with actual rows, loop counts, expensive nodes, and disk sorts.

ANALYZE executes the statement

EXPLAIN ANALYZE DELETE ... really deletes. Inspect a write with BEGIN; EXPLAIN (ANALYZE, BUFFERS) ...; ROLLBACK; only after confirming there are no non-transactional external effects.

Index the query shape

The filter and ordering above can use:

CREATE INDEX CONCURRENTLY orders_customer_placed_idx
ON orders (customer_id, placed_at DESC)
INCLUDE (id);

A multicolumn B-tree normally matches from its left side. Actual predicates, range conditions, and ordering determine column order—not a simplistic “most selective first” rule.

INCLUDE columns do not participate in search ordering but may enable an index-only scan. The visibility map still determines whether heap access is avoidable.

Common index types

TypeFits
B-treeEquality, ranges, ordering; the default
GINjsonb containment, arrays, full-text search
GiSTGeometry, ranges, and extension operators
SP-GiSTTries, quadtrees, k-d trees, and other partitioned search spaces
BRINHuge tables whose physical order correlates with values, such as append-only time data
HashEquality only; B-tree is usually more versatile
Bloom extensionEquality across arbitrary combinations of many columns; lossy and rechecked; bundled classes only cover int4 and text

These labels still do not prove an index is usable: the operator class determines exact operators and data types. See index and storage access methods for table access methods, HNSW/IVFFlat, and the full selection map.

Two high-value patterns

A partial index covers only relevant rows:

CREATE INDEX orders_unfinished_idx
ON orders (placed_at)
WHERE status IN ('pending', 'paid');

An expression index accelerates normalized lookup:

CREATE UNIQUE INDEX customers_email_ci_idx
ON customers (lower(email));

The query predicate must match the expression or imply the partial condition for the planner to use it.

Why an index is not used

  • The table is small and a sequential scan is cheaper.
  • The query returns a large fraction of rows.
  • Statistics are stale or miss cross-column correlation.
  • A function or implicit cast does not match the index expression.
  • The leftmost prefix of a multicolumn index is not usable.
  • Cost parameters do not reflect the storage system.

Run ANALYZE orders; and inspect estimate errors before disabling sequential scans.

Production creation and cleanup

CREATE INDEX CONCURRENTLY reduces write blocking but takes longer, cannot run in a transaction block, and can leave an invalid index after failure. Inspect with:

SELECT indexrelid::regclass, indisvalid, indisready
FROM pg_index
WHERE indrelid = 'orders'::regclass;

Every index adds write amplification, WAL, cache pressure, and vacuum work. Review unused indexes with pg_stat_user_indexes over a representative business cycle.

Last updated on

On this page