Index Types

Single-column index: Index on one column. Best for equality (WHERE email = ?) and range (WHERE created_at > ?) queries on that column.

graph LR idx["Index on (a, b, c)<br/>sorted by a, then b within a, then c within b"] idx -.->|"usable"| q1["WHERE a = ?"] idx -.->|"usable"| q2["WHERE a = ? AND b = ?"] idx -.->|"usable"| q3["WHERE a = ? AND b = ? AND c = ?"] idx -.->|"NOT usable — b isn't the leading column"| q4["WHERE b = ?"]

Composite (multi-column) index: Index on (a, b, c) sorted first by a, then b within equal a, then c. Useful for queries that filter on a leading prefix: WHERE a = ?, WHERE a = ? AND b = ?, WHERE a = ? AND b = ? AND c = ?. Cannot be used for WHERE b = ? alone — b is not the leading column, the index is not sorted by b globally. The rule: leftmost prefix. Column order matters: put the most selective (highest cardinality) column first when multiple queries use the index with different predicates.

Unique index: Enforces uniqueness. UNIQUE constraint automatically creates a unique index. The index serves dual purpose: lookup performance and constraint enforcement. Slightly more overhead on write than a regular index (must check for duplicates).

Partial index: CREATE INDEX idx ON orders (customer_id) WHERE status = 'pending'. Only pending orders are indexed — the index is smaller, writes to non-pending rows don't update it. Highly effective when a large fraction of rows are inactive and queries always filter by the partial predicate.

Covering index (index-only scan): CREATE INDEX idx ON orders (customer_id) INCLUDE (total, created_at). The INCLUDE columns are stored in the leaf nodes but not used for sorting. A query SELECT total, created_at FROM orders WHERE customer_id = ? can be answered entirely from the index — no heap fetch. Eliminates the most expensive part of index access (random I/O to heap pages).

Hash index: CREATE INDEX idx USING hash ON users (email). O(1) equality lookup, no range support. In PostgreSQL, B+Tree is usually preferred because it handles both equality and range, and hash indexes historically didn't support WAL (fixed in PG10 but B+Tree is still more common).

GIN (Generalized Inverted Index): Used for full-text search, JSONB columns, and arrays. GIN builds an inverted map: token → list of documents containing that token. WHERE doc_vector @@ query uses GIN to find all matching documents in O(log n + k). Write overhead is high — every new document requires updating the GIN for each token.

When NOT to index: Small tables (< 1000 rows): sequential scan is fast enough; index overhead (space, write cost) is not worth it. Low-cardinality columns (e.g., boolean, gender with 2 values): an index scan on 50% of rows is slower than a sequential scan. Write-heavy tables with many reads from analytics (not OLTP): index maintenance cost dominates.

Index fragmentation: Heavy DELETE workloads leave index pages partially empty. PostgreSQL does not immediately reclaim this space. A VACUUM marks dead tuples reclaimable but does not compact the index. REINDEX rebuilds the index from scratch — compacts it, removes all dead entries — but requires an ACCESS EXCLUSIVE lock (blocks reads+writes). In PostgreSQL 12+, REINDEX CONCURRENTLY rebuilds without locking.

Common pitfall

Creating a composite index (a, b, c) and then writing queries that filter on b and c but not a gets zero benefit from it — per the leftmost-prefix rule above, an index is only usable starting from its leading column. This is easy to introduce accidentally as an application evolves: the index was correct for the query patterns that existed when it was created, and nothing fails loudly when a new query pattern silently stops using it — EXPLAIN on the new query is the only way to notice, since a missing index doesn't error, it just falls back to a sequential scan.