Postgres Indexing skill for Claude Code
Every index justified by a plan, every drop by data. Claude designs the narrowest index that serves a named query: equality columns before range columns, partial predicates for queue and soft-delete tables, INCLUDE columns for index-only scans, trigram GIN for ILIKE search. Builds run CONCURRENTLY with a lock timeout and an invalid-index check, and a diagnostic SQL catalog finds unused, duplicate and FK-missing indexes before any drop.
- name
postgres-indexing - size204 lines · ≈2,200 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs, builds, and audits PostgreSQL indexes - multicolumn B-tree ordering, partial, covering (INCLUDE), expression, GIN for JSONB/arrays/trigram search, GiST, and BRIN - built with CREATE INDEX CONCURRENTLY, plus finding unused, duplicate, invalid, and bloated indexes to drop. Use when EXPLAIN shows a Seq Scan or Filter on a big table, adding a new WHERE/ORDER BY pattern, reviewing a migration that creates indexes, or when writes slowed down and index count is suspect. Try asking
- Which index should I add for this tickets inbox query?
- Our orders table has 23 indexes and inserts are slow - which can go?
- Make this ILIKE '%term%' product search use an index
Use it when
- EXPLAIN shows a Seq Scan or a large Rows Removed by Filter on a big table
- A migration creates or changes indexes and needs review
- Write latency rose and the table carries many indexes
- You need fast search on JSONB, arrays, or substring matches
Skip it when
- You do not yet know which query is slow - start with sql-query-tuning
- The index needs pgvector HNSW or IVFFlat tuning - use vector-search
How to check the result
EXPLAIN (ANALYZE, BUFFERS) before and after shows the new index in the plan with far fewer buffers, and pg_index reports no INVALID indexes left behind.
The common mistake it prevents
Putting the range or sort column first in a multicolumn index forces the scan to read every tenant's rows in the range instead of jumping straight to one tenant.
An index earns its place when a named, frequent query uses it (proven by EXPLAIN), it is the narrowest index that serves that query, and its write and storage cost is acceptable. Every index slows every INSERT and most UPDATEs on its table; add with evidence, drop with evidence.
Workflow
- [ ] 1. Collect the queries the index must serve, with real parameters
- [ ] 2. Inspect existing indexes on the table (avoid duplicates/near-duplicates)
- [ ] 3. Pick the index type and column order (tables below)
- [ ] 4. Narrow it: partial predicate, INCLUDE columns, expression
- [ ] 5. Build it CONCURRENTLY, check it is valid
- [ ] 6. Prove it with EXPLAIN (ANALYZE, BUFFERS) before/after
- [ ] 7. Audit for unused/duplicate indexes to drop (with user confirmation)
1-2. Inspect before adding
\d orders -- existing indexes and constraints
SELECT indexrelid::regclass AS index, pg_size_pretty(pg_relation_size(indexrelid)) AS size,
idx_scan, pg_get_indexdef(indexrelid) AS def
FROM pg_stat_user_indexes JOIN pg_index USING (indexrelid)
WHERE relid = 'orders'::regclass
ORDER BY pg_relation_size(indexrelid) DESC;
If an existing index has your proposed columns as a prefix, or your proposed index is a prefix of an existing one, extend or reuse rather than add.
3. Choose the type
| Query pattern | Index |
|---|---|
=, <, >, BETWEEN, ORDER BY, IN, LIKE 'abc%' (C collation or text_pattern_ops) | B-tree (default) |
JSONB containment @>, key existence ? | GIN (jsonb_path_ops if only @>) |
Array @>, && | GIN |
ILIKE '%term%', similarity | GIN with pg_trgm (gin_trgm_ops) |
Full-text @@ to_tsquery | GIN on tsvector (stored generated column or expression) |
Ranges &&, geometry, nearest neighbour, exclusion constraints | GiST (or SP-GiST for some geometric/text cases) |
| Huge append-only table filtered by a column correlated with insert order (time) | BRIN |
| Equality only, large keys, never range | Hash (rarely better than B-tree; default to B-tree) |
| Vector similarity | HNSW/IVFFlat via pgvector (see vector-search) |
4. Column order in multicolumn B-trees
Rule: equality columns first, then the range or sort column, then optional INCLUDE columns for index-only scans.
-- Query: WHERE tenant_id = $1 AND status = 'open' ORDER BY created_at DESC LIMIT 50
CREATE INDEX CONCURRENTLY tickets_tenant_status_created_idx
ON tickets (tenant_id, status, created_at DESC);
- A range predicate stops further index columns from narrowing the scan.
(created_at, tenant_id)for the query above reads every tenant’s rows in the time range. ORDER BY a, b DESCneeds index order matching all directions (or all reversed).- Leading-column rule: before PG18, an index on
(a, b)is effectively useless forWHERE b = ?. PG18 adds B-tree skip scan, which helps whenahas few distinct values; do not rely on it for high-cardinality leading columns.
5. Narrow it
Partial - index only the rows queries ask for:
CREATE INDEX CONCURRENTLY jobs_pending_run_at_idx
ON jobs (run_at) WHERE status = 'pending';
-- The query's WHERE must imply the predicate: WHERE status = 'pending' AND run_at <= now()
Good fits: soft-deleted rows excluded (WHERE deleted_at IS NULL), queue tables, rare
flags. A partial index is not used if the predicate is a bind parameter the planner
cannot prove matches (e.g. status = $1 with a generic plan).
Covering - avoid heap fetches for hot read paths:
CREATE INDEX CONCURRENTLY orders_customer_placed_idx
ON orders (customer_id, placed_at DESC) INCLUDE (total_cents, status);
Index-only scans also need the visibility map to be current; check Heap Fetches in
EXPLAIN and vacuum health if it stays high.
Expression - index exactly what the WHERE clause computes:
CREATE UNIQUE INDEX CONCURRENTLY users_email_lower_key ON users (lower(email));
-- Must query with the same expression: WHERE lower(email) = lower($1)
CREATE INDEX CONCURRENTLY events_payload_type_idx ON events ((payload ->> 'type'));
Expression functions must be IMMUTABLE. date(timestamptz_col) is not (depends on
time zone); index (created_at AT TIME ZONE 'UTC')::date or rewrite the query to a range.
Trigram search:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX CONCURRENTLY products_name_trgm_idx ON products USING gin (name gin_trgm_ops);
BRIN for time series (tiny, but only useful when physical order tracks the column):
CREATE INDEX CONCURRENTLY metrics_recorded_brin ON metrics USING brin (recorded_at);
6. Build safely
SET lock_timeout = '5s'; -- fail fast instead of queueing behind long transactions
CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_customer_id_idx ON orders (customer_id);
CONCURRENTLYdoes not block reads or writes but takes longer, waits for existing transactions, and cannot run inside a transaction block. Many migration tools wrap each migration in a transaction; disable that per migration (Railsdisable_ddl_transaction!, Djangoatomic = False, Prisma/Drizzle: separate raw SQL migration run outside a transaction, golang-migrate/Flyway: one statement per file).- A failed concurrent build leaves an
INVALIDindex that still costs writes. Find and drop it, then retry:
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
DROP INDEX CONCURRENTLY IF EXISTS orders_customer_id_idx;
- Monitor progress:
SELECT * FROM pg_stat_progress_create_index; - Speed up big builds in a session:
SET maintenance_work_mem = '2GB';max_parallel_maintenance_workerscontrols parallel B-tree builds. - Unique constraints on existing tables:
CREATE UNIQUE INDEX CONCURRENTLY ...thenALTER TABLE ... ADD CONSTRAINT ... UNIQUE USING INDEX .... - Rebuild bloated indexes without blocking:
REINDEX INDEX CONCURRENTLY idx_name;(PG12+).
7. Prove it
EXPLAIN (ANALYZE, BUFFERS) SELECT ... ; -- before and after, same parameters
Done when the target query uses the new index (Index Scan, Index Only Scan, or
Bitmap Index Scan on it), buffers and time drop materially, and the planner is not
choosing it only because the table is tiny in dev.
To test an index idea without building it on a big table, the hypopg extension creates
hypothetical indexes visible to plain EXPLAIN (not ANALYZE), if it is installed.
Auditing and dropping
Run the diagnostic queries in references/index-diagnostics.md for: unused indexes, duplicates and prefix-redundant indexes, invalid indexes, FK columns without indexes, bloat estimates, and HOT-update blockers.
Before dropping any index, confirm all of these and ask the user to approve the list:
idx_scan = 0over a window covering monthly jobs and reports. Check when stats were last reset:SELECT stats_reset FROM pg_stat_database WHERE datname = current_database();PG16+ also haslast_idx_scaninpg_stat_user_indexes.- Usage on every replica:
pg_stat_user_indexesis per-instance; a read replica may be the only place the index is used. - It does not back a PRIMARY KEY, UNIQUE, or exclusion constraint (those enforce rules even with zero scans).
- Keep the
CREATE INDEXstatement in the PR so it can be restored.
DROP INDEX CONCURRENTLY IF EXISTS orders_legacy_status_idx;
Gotchas
- Indexing every column “just in case” multiplies write cost and blocks HOT updates: an UPDATE that changes any indexed column cannot be a heap-only tuple update.
- Low-selectivity columns alone (booleans,
statuswith 3 values) rarely help unless partial or combined with a selective column. LIKE 'abc%'uses a B-tree only with C collation ortext_pattern_ops/varchar_pattern_ops.- An index on
(a)is redundant if(a, b)exists, except when(a)is unique or much smaller and very hot. - GIN indexes with
fastupdatecan make some reads slow while the pending list is large; for write-heavy tables watchgin_pending_list_limit. - Indexes on foreign keys are not automatic in Postgres. Parent
DELETE/UPDATEof the key scans the child table without one. - Large index builds generate WAL quickly; watch replica lag during the build.
Output: index change note
**Query served:** tickets inbox (queryid 4410), 12k calls/hour.
**Index:** `CREATE INDEX CONCURRENTLY tickets_tenant_status_created_idx ON tickets (tenant_id, status, created_at DESC);`
**Evidence:** Seq Scan 640 ms / 98k buffers -> Index Scan 1.8 ms / 41 buffers.
**Cost:** 310 MB; insert p95 unchanged in load test.
**Dropped (approved):** tickets_status_idx (0 scans since 2026-07-01 on primary and both replicas). Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/index-diagnostics.md
# Index diagnostic queries
Read this when auditing indexes on an existing database. All queries are read-only and
safe to run on production, though the bloat estimate scans catalog stats only (not data).
Run usage queries on the primary and on every replica.
## Unused indexes (excluding constraint-backing ones)
```sql
SELECT s.schemaname,
s.relname AS table,
s.indexrelname AS index,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS size,
s.idx_scan
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
AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = s.indexrelid)
ORDER BY pg_relation_size(s.indexrelid) DESC;
```
Window check:
```sql
SELECT stats_reset FROM pg_stat_database WHERE datname = current_database();
-- PG16+: per-index last use
SELECT indexrelname, last_idx_scan FROM pg_stat_user_indexes ORDER BY last_idx_scan NULLS FIRST;
```
## Rarely used, large indexes (candidates for review, not automatic drops)
```sql
SELECT relname AS table, indexrelname AS index, idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS size,
n_tup_ins + n_tup_upd + n_tup_del AS table_writes
FROM pg_stat_user_indexes
JOIN pg_stat_user_tables USING (relid)
WHERE idx_scan < 50
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 30;
```
## Exact duplicate indexes
```sql
SELECT pg_size_pretty(sum(pg_relation_size(idx))::bigint) AS size,
(array_agg(idx))[1] AS idx1, (array_agg(idx))[2] AS idx2,
(array_agg(idx))[3] AS idx3
FROM (
SELECT indexrelid::regclass AS idx,
(indrelid::text || E'\n' || indclass::text || E'\n' || indkey::text || E'\n' ||
coalesce(indexprs::text, '') || E'\n' || coalesce(indpred::text, '')) AS key
FROM pg_index
) sub
GROUP BY key
HAVING count(*) > 1
ORDER BY sum(pg_relation_size(idx)) DESC;
```
## Prefix-redundant indexes
An index whose column list is a leading prefix of another index on the same table, with
the same predicate and no uniqueness role.
```sql
SELECT a.indrelid::regclass AS table,
a.indexrelid::regclass AS redundant_index,
b.indexrelid::regclass AS covered_by,
pg_size_pretty(pg_relation_size(a.indexrelid)) AS size
FROM pg_index a
JOIN pg_index b
ON a.indrelid = b.indrelid
AND a.indexrelid <> b.indexrelid
AND a.indnatts < b.indnatts
AND (b.indkey::text || ' ') LIKE (a.indkey::text || ' %')
WHERE NOT a.indisunique
AND a.indpred IS NULL AND b.indpred IS NULL
AND a.indexprs IS NULL AND b.indexprs IS NULL
ORDER BY pg_relation_size(a.indexrelid) DESC;
```
Verify the access methods match (`pg_get_indexdef`) before acting; a B-tree prefix is not
made redundant by a GIN index.
## Invalid indexes (failed CONCURRENTLY builds)
```sql
SELECT indexrelid::regclass AS index, indrelid::regclass AS table
FROM pg_index WHERE NOT indisvalid;
```
## Foreign keys without a supporting index
```sql
SELECT c.conrelid::regclass AS table,
c.conname AS fk,
pg_get_constraintdef(c.oid) AS definition
FROM pg_constraint c
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid
AND (i.indkey::int2[])[0:cardinality(c.conkey) - 1] @> c.conkey
AND (i.indkey::int2[])[0:cardinality(c.conkey) - 1] <@ c.conkey
)
ORDER BY 1;
```
## Tables with heavy sequential scans
```sql
SELECT relname, seq_scan, seq_tup_read, idx_scan,
seq_tup_read / greatest(seq_scan, 1) AS avg_rows_per_seq_scan,
pg_size_pretty(pg_relation_size(relid)) AS size
FROM pg_stat_user_tables
WHERE seq_scan > 0
ORDER BY seq_tup_read DESC
LIMIT 20;
```
Large `avg_rows_per_seq_scan` on a big table points to a missing index; small tables are
expected to be seq-scanned.
## HOT update ratio (index count hurting writes)
```sql
SELECT relname, n_tup_upd, n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) AS hot_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 10000
ORDER BY hot_pct NULLS FIRST;
```
Low `hot_pct` on update-heavy tables: an indexed column changes on most updates, or pages
lack free space (consider `fillfactor = 90` on that table).
## Index bloat (estimate)
For a quick signal without extensions, compare index size to the table and to expected
size after a rebuild on a staging copy. For accurate numbers, use the `pgstattuple`
extension (reads the whole index; run off-peak):
```sql
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstatindex('orders_customer_id_idx');
-- avg_leaf_density well below ~70% on a B-tree suggests bloat; REINDEX CONCURRENTLY
```
## Cache hit ratio per index
```sql
SELECT indexrelname, idx_blks_hit, idx_blks_read,
round(100.0 * idx_blks_hit / nullif(idx_blks_hit + idx_blks_read, 0), 1) AS hit_pct
FROM pg_statio_user_indexes
ORDER BY idx_blks_read DESC
LIMIT 20;
```
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.