diff --git a/docs/rules/unused_index.md b/docs/rules/unused_index.md new file mode 100644 index 0000000..dc3a930 --- /dev/null +++ b/docs/rules/unused_index.md @@ -0,0 +1,82 @@ +# unused_index + +> **Severity:** info +> Indexes that have never been scanned, per `pg_stat_user_indexes.idx_scan = 0`. + +## What it catches + +A non-correctness index whose `idx_scan` counter is zero in `pg_stat_user_indexes` — i.e. nothing has used it for a lookup since stats were last reset. + +```sql +CREATE INDEX cold_lookup ON orders (legacy_external_id); +-- months pass, no query references legacy_external_id +-- ⚠️ cold_lookup is flagged: idx_scan = 0 +``` + +Excluded by design — these indexes provide correctness, not just an access path: + +- `indisprimary` — primary keys. +- `indisunique` — unique constraints / unique indexes. +- `indisexclusion` — EXCLUDE constraints (range overlap, etc.). +- `indisvalid = false` — failed `CREATE INDEX CONCURRENTLY`; never scanned by design. + +## Why it matters + +An unused index is pure write-cost with no read benefit: + +### 1. Every write pays for the index + +Postgres updates **every** index whose key columns are touched by an `INSERT` / `UPDATE`. A 50M-row table with three unused indexes pays three times the WAL volume, three times the autovacuum work, and three times the index-leaf-page cache pressure on each write — for queries that never use them. + +### 2. Disk and shared-buffer space + +Cold indexes still occupy disk pages and compete for shared-buffer cache against indexes that actually pull weight. On a hot table, evicting useful pages to keep cold ones warm is a quiet performance regression. + +### 3. Maintenance amplification + +`VACUUM`, `REINDEX`, and `ANALYZE` all walk every index. Cold indexes lengthen routine maintenance and bulk migrations (e.g. `pg_repack`) for no payoff. + +This is **info severity** because the cost is real but rarely incident-causing, and false positives are easy to trip into (see below). + +## How to fix + +Drop it. Use `CONCURRENTLY` on a live system: + +```sql +DROP INDEX CONCURRENTLY public.cold_lookup; +``` + +If the index has a clear owner (a feature flag, a batch job, a DR fallback), record the rationale in a baseline entry instead of dropping: + +```bash +pgsleuth baseline write --dsn $DSN +``` + +## When to ignore + +A finding is not always a defect. Vet a flagged index before dropping: + +- **Recently created index.** `idx_scan = 0` is the same value a brand-new index reports until its first scan. Wait at least one statistics window (a day, a week — whatever your traffic shape demands) before acting. +- **Stats reset recently.** `pg_stat_reset()` or a `pg_stat_statements_reset()` zeros the counters. Cross-check with `pg_stat_get_db_stat_reset_time(d.oid)` from `pg_database`. +- **Index used only by infrequent jobs.** Monthly reports, end-of-quarter audits, DR-failover queries. Real workloads, just not in the recent stats window. +- **Index used only on the primary, but pgsleuth is run against a replica.** `idx_scan` on a replica reflects replica reads, not primary reads — a hot primary index can show zero on the replica. +- **Hot-standby or read-replica role.** Same caveat — verify against the right node. + +If you're keeping a flagged index deliberately, suppress just that finding via baseline mode (preferred) or disable the rule cluster-wide: + +```toml +[pgsleuth.checkers.unused_index] +enabled = false +``` + +Or escalate severity once you've cleaned house and want CI to fail on *new* unused indexes: + +```toml +[pgsleuth.checkers.unused_index] +severity = "warning" +``` + +## See also + +- [`redundant_index`](redundant_index.md) — finds prefix-redundant indexes via the catalog (no stats needed). +- PostgreSQL documentation — [Statistics views: `pg_stat_user_indexes`](https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-ALL-INDEXES-VIEW). diff --git a/src/pgsleuth/checkers/__init__.py b/src/pgsleuth/checkers/__init__.py index 0893a62..c546718 100644 --- a/src/pgsleuth/checkers/__init__.py +++ b/src/pgsleuth/checkers/__init__.py @@ -14,5 +14,6 @@ sequence_drift, three_state_boolean, timestamp_without_tz, + unused_index, varchar_length, ) diff --git a/src/pgsleuth/checkers/unused_index.py b/src/pgsleuth/checkers/unused_index.py new file mode 100644 index 0000000..5a3a7fa --- /dev/null +++ b/src/pgsleuth/checkers/unused_index.py @@ -0,0 +1,71 @@ +"""Indexes that have never been scanned, per `pg_stat_user_indexes.idx_scan = 0`. + +Pure write-cost: every INSERT / UPDATE on the table maintains the index, +autovacuum reads its pages, it eats disk and shared buffers — and queries +pay nothing back. Drop or justify. + +The check is intentionally narrow. Indexes that enforce correctness, not just +provide an access path, are excluded: + +- `indisprimary` — primary keys. +- `indisunique` — unique constraints. +- `indisexclusion` — EXCLUDE constraints. +- `indisvalid = false` — failed CONCURRENT builds; nothing scans them by design. + +Stats are read live from the cluster, so a freshly-created index always looks +"unused" until something scans it. Treat the report as a starting list, not a +verdict — the rule doc explains how to vet a finding before dropping. +""" + +from __future__ import annotations + +from typing import ClassVar + +from pgsleuth.checkers.base import Issue, RowChecker, Severity, register +from pgsleuth.context import CheckerContext + +_SQL = """ +SELECT + n.nspname AS schema, + t.relname AS table, + ic.relname AS index_name, + s.idx_scan AS scans +FROM pg_stat_user_indexes s +JOIN pg_index i ON i.indexrelid = s.indexrelid +JOIN pg_class ic ON ic.oid = i.indexrelid +JOIN pg_class t ON t.oid = i.indrelid +JOIN pg_namespace n ON n.oid = t.relnamespace +WHERE s.idx_scan = 0 + AND i.indisvalid + AND NOT i.indisprimary + AND NOT i.indisunique + AND NOT i.indisexclusion + AND t.relkind IN ('r', 'p') + {schema_filter} +ORDER BY n.nspname, t.relname, ic.relname; +""" + + +class UnusedIndex(RowChecker): + name: ClassVar[str] = "unused_index" + description: ClassVar[str] = ( + "Indexes never scanned per pg_stat_user_indexes — pure write-cost, no read benefit." + ) + default_severity: ClassVar[Severity] = Severity.INFO + sql: ClassVar[str] = _SQL + + def check_row(self, ctx: CheckerContext, row: dict) -> Issue | None: + obj = f"{row['schema']}.{row['index_name']}" + return self.issue( + ctx, + object_type="index", + object_name=obj, + message=( + f"Index {row['index_name']!r} on {row['schema']}.{row['table']} " + f"has never been scanned (pg_stat_user_indexes.idx_scan = 0)." + ), + suggestion=f"DROP INDEX CONCURRENTLY {row['schema']}.{row['index_name']};", + ) + + +register(UnusedIndex) diff --git a/tests/checkers/test_unused_index.py b/tests/checkers/test_unused_index.py new file mode 100644 index 0000000..c5e03df --- /dev/null +++ b/tests/checkers/test_unused_index.py @@ -0,0 +1,61 @@ +from pgsleuth.checkers.unused_index import UnusedIndex + + +def test_flags_never_scanned_index(ctx, conn, schema): + """A vanilla index with zero scans is flagged.""" + with conn.cursor() as cur: + cur.execute("CREATE TABLE t (a int, b int)") + cur.execute("CREATE INDEX cold_idx ON t (b)") + + issues = [i for i in UnusedIndex().run(ctx) if i.object_name.startswith(schema)] + assert len(issues) == 1 + assert issues[0].object_name.endswith(".cold_idx") + assert "idx_scan = 0" in issues[0].message + assert "DROP INDEX" in issues[0].suggestion + + +def test_clean_when_index_is_primary_key(ctx, conn, schema): + """Primary-key indexes enforce correctness — never flagged as unused.""" + with conn.cursor() as cur: + cur.execute("CREATE TABLE t (id bigserial PRIMARY KEY, a int)") + + issues = [i for i in UnusedIndex().run(ctx) if i.object_name.startswith(schema)] + assert issues == [] + + +def test_clean_when_index_is_unique_constraint(ctx, conn, schema): + """UNIQUE constraints' backing indexes enforce correctness — not flagged.""" + with conn.cursor() as cur: + cur.execute("CREATE TABLE t (id bigserial PRIMARY KEY, email text UNIQUE)") + + issues = [i for i in UnusedIndex().run(ctx) if i.object_name.startswith(schema)] + assert issues == [] + + +def test_clean_when_index_is_exclusion_constraint(ctx, conn, schema): + """EXCLUDE constraints' backing indexes enforce correctness — not flagged.""" + with conn.cursor() as cur: + cur.execute("CREATE EXTENSION IF NOT EXISTS btree_gist") + cur.execute( + "CREATE TABLE bookings (" + " id bigserial PRIMARY KEY," + " room_id int," + " during tsrange," + " EXCLUDE USING gist (room_id WITH =, during WITH &&)" + ")" + ) + + issues = [i for i in UnusedIndex().run(ctx) if i.object_name.startswith(schema)] + assert issues == [] + + +def test_flags_only_unused_when_mixed(ctx, conn, schema): + """A table with one unused secondary index and one PK — only the secondary is flagged.""" + with conn.cursor() as cur: + cur.execute("CREATE TABLE t (id bigserial PRIMARY KEY, a int, b int)") + cur.execute("CREATE INDEX cold_a ON t (a)") + cur.execute("CREATE INDEX cold_b ON t (b)") + + issues = [i for i in UnusedIndex().run(ctx) if i.object_name.startswith(schema)] + flagged = sorted(i.object_name.rsplit(".", 1)[-1] for i in issues) + assert flagged == ["cold_a", "cold_b"]