skip to content

On a busy production database, how would you identify indexes that are unused or redundant, and what would you verify before dropping one?

level: seniorimportance: must knowfreq 50%

answer

  1. per-index usage counters + full business cycle
  2. zero uses ≠ unused: replicas, resets, annual jobs
  3. (a) redundant when (a,b) exists — leftmost prefix
  4. check unique/PK/FK backing before dropping
  5. invisible/disable first; script the CREATE before the DROP

basics

~20 s

Read the engine's per-index usage counters over a full business cycle to find never-scanned indexes, and compare definitions to find ones whose columns are a leading prefix of another index. Before dropping: confirm the counters cover all replicas and periodic jobs, check the index does not back a constraint, and make it reversible.

solid answer

~1 min

**Finding candidates.** - *Unused*: every engine exposes per-index scan/seek counters. Reset or snapshot them, then let a full business cycle pass — month-end, quarterly reports, annual jobs — before trusting a zero. - *Redundant*: an index on `(a)` is redundant when `(a, b)` exists, because the leftmost prefix serves the same predicates. Also look for exact duplicates under different names, and index pairs differing only by a trailing column that is never used. - *Overlapping*: two indexes with the same leading columns can often be consolidated into one wider index. **Before dropping, verify:** 1. The index does not enforce a **unique constraint or primary key**, and is not required by a foreign key. 2. Usage was collected from **every replica** — read replicas run different queries, and counters are per-node. 3. Counters were not reset by a restart or failover mid-window. 4. It is not the only index supporting a rare but critical job (nightly reconciliation, disaster reports). **How to drop safely.** Prefer disabling/invisible-marking the index first where supported, so it stops being used for planning but can be re-enabled instantly. Otherwise, script the exact `CREATE INDEX` statement first, drop during a low-traffic window, and monitor plan changes on the table's top queries.

code

text · 7 lines
text
orders:
  pk_orders (order_id)                 <- constraint, keep
  ix_cust (customer_id)                <- redundant: prefix of ix_cust_created
  ix_cust_created (customer_id, created_at)
  ix_created (created_at)              <- different leading column, keep if used
  ix_status (status)                   <- 0 scans in 90 days on all nodes -> drop
  ix_orders_customer (customer_id)     <- exact duplicate of ix_cust -> drop

go deeper

for a junior

Say you would check the engine's index usage statistics and look for indexes whose columns are already the leading columns of another index.

for a middle

Add the observation-window discipline, the leftmost-prefix rule for redundancy, and the check that the index does not enforce a constraint.

for a senior

Own the whole procedure: multi-node counter collection, counter-reset checks, rare-job discovery in code and schedulers, invisible-index trials, one-at-a-time drops with monitoring and a scripted rollback.

for a principal

Make it a standing process rather than a cleanup: index review as part of schema change review, a budget per table tied to its write service level, and telemetry that surfaces index cost alongside query benefit.

## Why this is a real job, not housekeeping Indexes accumulate. A report needs a filter, someone adds an index; a slow query appears, someone adds another; a migration adds one "just in case". Nothing ever removes them, because removal feels risky and adding felt safe. The result is a table where every insert fans out across a dozen structures, the working set no longer fits in memory, and write latency has crept up over quarters in a way nobody can attribute to a single change. Meanwhile most of those indexes serve nothing. ## Finding unused indexes Every mainstream engine maintains per-index usage counters — how many times an index was scanned or sought, and how many rows were read through it. The method is: 1. **Snapshot or reset** the counters and record the timestamp. 2. **Wait a full business cycle.** This is the step people skip. Weekly reports, month-end closes, quarterly compliance extracts, and annual jobs all use indexes that look dead on a seven-day window. A month is a reasonable floor; a quarter is safer for financial systems. 3. **Collect from every node.** Counters are local. A read replica serving analytics uses a completely different set of indexes than the primary, and dropping based on the primary's counters alone will break the replica's workload. 4. **Check for resets.** A restart, failover, or crash zeroes the counters. A "zero uses" reading from a counter that reset yesterday means nothing. Also rank candidates by cost, not just by zero usage: a large unused index on a hot write table is worth removing far more than a tiny unused index on a static lookup table. ## Finding redundant indexes Redundancy is found by reading definitions, not counters: - **Prefix redundancy.** With `(customer_id, created_at)` present, a separate index on `(customer_id)` is almost always redundant — the composite serves every predicate the single-column one does, because a B+Tree can be probed on a leftmost prefix of its key. The narrow one is slightly smaller and thus slightly cheaper to scan, but rarely enough to justify its write cost. - **Exact duplicates.** The same column list under two names, created by two migrations. Pure waste. - **Near-duplicates.** `(a, b)` and `(a, c)`: not redundant — each serves different second-column predicates — but a candidate for consolidation into `(a, b, c)` *only* if the query patterns permit, which needs checking rather than assuming. - **Column-order duplicates.** `(a, b)` and `(b, a)` are genuinely different indexes serving different leading predicates; do not treat them as duplicates. Be careful with one exception: a narrow index is sometimes kept deliberately because it is much smaller and stays cached for a very hot lookup, or because it enables an index-only read that the wider one also enables but at a larger footprint. Those are measurable claims and should be measured, not assumed. ## The verification checklist before dropping 1. **Constraint backing.** Unique indexes enforce uniqueness; primary key indexes enforce identity. Dropping them removes a data-integrity guarantee, not just a performance path. Foreign-key relationships may also depend on an index on the referencing or referenced side, and in some engines removing it turns every referential check into a scan. 2. **Replica coverage.** Confirmed for every node in the topology. 3. **Rare-but-critical jobs.** Search the codebase and the scheduler for queries against the table. An index used twice a year by the regulatory extract is still load-bearing. 4. **Counter integrity.** Verify uptime exceeds the observation window. 5. **Reversibility.** Capture the exact creation statement, including column order, uniqueness, included columns, and any predicate. Rebuilding a large index later can take hours, so know that cost before you take the risk. ## Executing the drop - **Prefer a soft disable.** Several engines can mark an index invisible or disabled: the optimizer stops considering it while the index continues to be maintained. That gives a true production test with instant rollback. The catch is that maintenance cost continues during the trial, so it validates the read side, not the write saving. - **Drop in a low-traffic window** and prefer a non-blocking form if the engine offers one, since dropping can require a heavy lock. - **One at a time**, so that any regression has an obvious cause. - **Monitor afterwards**: plan shape and latency for the table's top queries, plus write throughput and index maintenance metrics to confirm the expected benefit actually appeared. ## Framing the payoff The benefit of a drop is not abstract tidiness. It is: fewer structures modified per write, lower log volume, less to ship to replicas, a smaller working set with a better cache hit rate, less background reclaim work, faster backups and restores, and fewer candidate paths for the optimizer to consider. On a write-heavy table, removing four dead indexes out of twelve is a measurable throughput change, and it is one of the few tuning actions that helps writes without hurting reads.

  • An index shows zero scans over 30 days. Why might dropping it still be wrong?
    The window may not cover a monthly or quarterly job that is the index's only consumer; the counters may have been reset by a restart or failover inside the window; the reading may be from the primary only while a read replica uses it heavily; or the index may back a unique constraint whose real job is data integrity rather than query speed.
  • When is a single-column index NOT redundant even though a composite index starts with that column?
    When the narrow index is small enough to stay fully cached while the composite is not, making a very hot lookup measurably faster; when the composite is much wider so scans through it read far more pages; or when uniqueness is declared on the single column, in which case the index is a constraint. These are claims to verify with measurements, not defaults.
  • What do you monitor after dropping an index to confirm it was safe?
    Plan shape and latency for the table's top queries, since a regression will show as a switch from an index path to a scan; error rates and timeouts on the services that read the table; and on the benefit side, write throughput, index maintenance time, log volume, and cache hit ratio. Keeping the exact creation statement on hand makes rollback a single command.

saying these in an interview costs you the question

  • Dropping based on a short observation window that misses periodic jobs.
  • Reading usage counters only from the primary and ignoring read replicas.
  • Not checking whether the index backs a unique constraint, primary key, or foreign-key check.
  • Treating (a, b) and (b, a) as duplicates of each other.
  • Dropping several indexes at once so any regression cannot be attributed.
  • Not recording the exact CREATE INDEX statement before dropping a large index.

context