Index Design Basics
Indexes accelerate lookups that match a left-to-right key prefix. Design for real query shapes: WHERE, JOIN, and ORDER BY columns that appear together in PostgreSQL 18.4 plans.
Recipe
Quick-reference recipe card - copy-paste ready.
CREATE INDEX CONCURRENTLY orders_customer_created_idx
ON orders (customer_id, created_at DESC);
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE customer_id = 1001
ORDER BY created_at DESC
LIMIT 50;When to reach for this: Frequent filters or joins show seq scans on large tables with provable selectivity.
Working Example
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
total numeric(12,2) NOT NULL
);
CREATE INDEX orders_customer_created_idx
ON orders (customer_id, created_at DESC);
INSERT INTO orders (customer_id, status, created_at, total)
SELECT
(random() * 5000)::bigint,
CASE WHEN random() < 0.1 THEN 'shipped' ELSE 'pending' END,
now() - (random() * interval '180 days'),
(random() * 200)::numeric(12,2)
FROM generate_series(1, 300000);
ANALYZE orders;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total, created_at
FROM orders
WHERE customer_id = 42
ORDER BY created_at DESC
LIMIT 25;What this demonstrates:
- Composite index matching filter plus sort
LIMITsatisfied without separate sort node when plan is ideal- Proof via
EXPLAINafterANALYZE
Deep Dive
How It Works
- B-tree is default: equality on leading columns, then range on next column.
INCLUDEcolumns enable index-only scans for selected payloads.WHEREpartial indexes shrink size for stable subsets (status = 'open').- Every index taxes
INSERT/UPDATE/DELETEwith WAL and buffer dirtying.
Design Checklist
| Query clause | Index hint |
|---|---|
WHERE a = ? AND b > ? | (a, b) |
JOIN ON child.parent_id | Index on parent_id |
ORDER BY created_at DESC | Match sort direction in index |
| Low-selectivity flag | Partial index with predicate |
SQL Notes
CREATE INDEX orders_open_customer_idx
ON orders (customer_id, created_at)
WHERE status = 'open';Gotchas
- Indexing every filter column separately - Planner may bitmap combine many weak indexes. Fix: One purposeful composite per hot query.
- Wrong column order -
(created_at, customer_id)failsWHERE customer_id = ?. Fix: Most selective equality column leftmost. - Wide indexes including JSON blobs - Bloated pages, slow vacuum. Fix:
INCLUDEonly needed scalars. - Creating index without CONCURRENTLY in prod - Writes block. Fix:
CREATE INDEX CONCURRENTLYin migrations. - Skipping ANALYZE after index - Planner ignores new index. Fix: Analyze table before validation.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| BRIN on time series | Append-only timestamps | Point lookups |
| GIN on JSONB keys | Containment queries | Simple scalar equality |
| Denormalized cache table | Extreme read skew | Strong normalization needs |
FAQs
How many indexes per table?
No fixed max; watch write rate and autovacuum pressure. Audit unused indexes quarterly.
UNIQUE vs plain INDEX?
UNIQUE enforces constraint and provides lookup path. Use UNIQUE when business rule requires.
Does DESC in index matter?
Yes for backward index scans avoiding sorts on ORDER BY ... DESC.
Covering index?
INCLUDE stores extra columns in leaf pages without search key ordering.
Hash index use?
Rare in modern PostgreSQL; B-tree handles most equality cases.
Index on expression?
CREATE INDEX ON lower(email) when queries use same expression.
FK without index?
Child join and cascade deletes suffer. Index child FK columns.
UUID primary keys?
Random UUIDs fragment indexes; consider time-ordered IDs for insert-heavy tables.
Monitoring usage?
pg_stat_user_indexes.idx_scan over rolling window.
Next?
Multicolumn index order for left-prefix rules.
Related
- Multicolumn Index Order - Leading column rules
- Duplicate & Redundant Indexes - Waste audit
- Seq Scan vs Index Scan - When index wins
- B-tree Indexes - Access path details
Stack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, and PostGIS 3.5+.