Advanced indexing

SQL · Advanced · 15 min

The right specialised index can turn a full scan into a millisecond lookup at a fraction of the size.

Beyond the plain B-tree, PostgreSQL offers several ways to make an index fit a query exactly.

An expression index indexes a computed value. A query on lower(email) cannot use an index on email, but it can use one on lower(email). The query must use the same expression.

A partial index covers only rows matching a predicate: CREATE INDEX ON orders (ordered_at) WHERE status = 'pending'. If pending orders are 1% of the table, the index is 1% of the size, and queries that include status = 'pending' can use it. A unique partial index enforces rules like "at most one active subscription per customer".

A covering index adds payload columns with INCLUDE: CREATE INDEX ON orders (customer_id) INCLUDE (total). A query needing only those columns can be answered by an index-only scan, without visiting the table, provided the visibility map says the pages are all-visible (vacuum maintains it).

Other index access methods:

  • GIN (inverted index): many keys per row. Use it for jsonb containment, arrays, full-text search, and trigram (pg_trgm) searches for LIKE '%term%'.
  • GiST: balanced trees over geometric data, ranges and nearest-neighbour searches (PostGIS uses it). It also powers exclusion constraints, such as "no overlapping bookings".
  • BRIN: stores only min/max per block range. Tiny, and very effective on huge tables whose values follow physical order, such as an append-only created_at. Useless when values are scattered.
  • Hash: equality only; rarely better than B-tree.

Every index adds write cost. Check pg_stat_user_indexes.idx_scan periodically and drop indexes that are never used.

Example

-- Expression index: case-insensitive email lookup
CREATE INDEX customers_email_lower_idx ON customers (lower(email));
SELECT id FROM customers WHERE lower(email) = 'ana@example.com';

-- Partial index: small, only the rows a hot query needs
CREATE INDEX orders_pending_idx ON orders (ordered_at) WHERE status = 'pending';
SELECT id FROM orders WHERE status = 'pending' ORDER BY ordered_at LIMIT 50;

-- Partial unique index: one pending order per customer
CREATE UNIQUE INDEX one_pending_per_customer
    ON orders (customer_id) WHERE status = 'pending';

-- Covering index enabling index-only scans
CREATE INDEX orders_cust_total_idx ON orders (customer_id) INCLUDE (total);
SELECT SUM(total) FROM orders WHERE customer_id = 2;

-- BRIN on a huge append-only table
CREATE INDEX events_created_brin ON events USING brin (created_at);

-- Unused indexes
SELECT indexrelname, idx_scan FROM pg_stat_user_indexes WHERE idx_scan = 0;

Remember

  • Expression indexes serve queries that use the same expression
  • Partial indexes are small and can enforce conditional uniqueness
  • INCLUDE columns enable index-only scans
  • GIN for jsonb/arrays/full text, GiST for ranges and geometry, BRIN for huge ordered tables

Watch out for

  • Creating an expression index on lower(email) but querying WHERE email ILIKE ..., which cannot use it.

Check yourself

  1. Which index best serves WHERE status = 'pending' ORDER BY ordered_at when 1% of orders are pending?
  2. When is a BRIN index a good choice?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.postgresql.org/docs/current/indexes-types.html