Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions docs/rules/unused_index.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions src/pgsleuth/checkers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@
sequence_drift,
three_state_boolean,
timestamp_without_tz,
unused_index,
varchar_length,
)
71 changes: 71 additions & 0 deletions src/pgsleuth/checkers/unused_index.py
Original file line number Diff line number Diff line change
@@ -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)
61 changes: 61 additions & 0 deletions tests/checkers/test_unused_index.py
Original file line number Diff line number Diff line change
@@ -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"]