Problem Statement
An index has been identified as unused and the change ticket says “drop it”. The operation takes under a second and is effectively irreversible on any timescale that matters during an incident, because recreating a GiST index on a large table is hours of work. This page covers the sequence that makes the drop safe, complementing the concurrent index build that created it.
Why the Naive Approach Fails
DROP INDEX idx_parcels_centroid; does exactly what it says, and two things go wrong with it.
The lock is the first. DROP INDEX needs ACCESS EXCLUSIVE on the table. On an idle table that is instant; on a table with a long-running report in progress, the drop waits — and every query arriving behind it waits too, because the lock queue is ordered. A one-second operation becomes a four-minute outage.
The asymmetry is the second, and it is worse. If the index turns out to have been needed, the recovery is a rebuild measured in hours, during which the queries that depended on it are running sequential scans.
Production-Ready Implementation
Step zero, before anything else: capture the statement that would bring it back.
SELECT indexdef || ';' AS recreate_statement
FROM pg_indexes
WHERE indexname = 'idx_parcels_centroid';
-- CREATE INDEX idx_parcels_centroid ON public.parcels USING gist (ST_Centroid(geom));Paste that into the change ticket. It costs ten seconds and it is the only thing standing between a bad decision and an unstructured afternoon.
Then hide the index rather than dropping it:
-- Reversible: the planner ignores the index, PostgreSQL keeps maintaining it.
BEGIN;
UPDATE pg_index SET indisvalid = false
WHERE indexrelid = 'idx_parcels_centroid'::regclass;
COMMIT;
-- to restore, at any time, instantly:
-- UPDATE pg_index SET indisvalid = true WHERE indexrelid = 'idx_parcels_centroid'::regclass;Modifying a system catalogue is normally a bad idea, and this is the documented exception the PostgreSQL community uses for exactly this purpose. The flag is the same one a failed concurrent build sets, so the state is one the database already understands.
Observe for a full cycle — at minimum a week, and long enough to include any monthly job:
import psycopg
WATCHED = ("/api/parcels/search", "/api/parcels/export")
def latency_snapshot(dsn: str) -> dict[str, float]:
"""Mean execution time per tagged endpoint, for before/after comparison."""
out = {}
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
for endpoint in WATCHED:
cur.execute("""
SELECT coalesce(sum(total_exec_time) / nullif(sum(calls), 0), 0)
FROM pg_stat_statements
WHERE query LIKE %s
""", (f"/* {endpoint} */%",))
out[endpoint] = round(cur.fetchone()[0], 2)
return outComparing the snapshot from before hiding with one taken a week later is the evidence the drop is safe. If nothing moved, the index was not contributing.
Finally, the drop itself:
-- outside any transaction; weak lock; safe on a live table
SET lock_timeout = '5s';
DROP INDEX CONCURRENTLY IF EXISTS idx_parcels_centroid;Configuration and Tuning Knobs
lock_timeout applies even to the concurrent form, which still needs a brief lock at the start. Five seconds plus a retry is the standard shape.
Autocommit is required. DROP INDEX CONCURRENTLY cannot run inside a transaction block, so a migration tool that wraps statements needs the same escape as the build did.
IF EXISTS makes the operation idempotent, which matters because a partially completed concurrent drop can leave the index in an invalid state that a re-run must tolerate.
Retention of the recreate statement is a process knob rather than a database one: keep it until at least one full release and one full reporting cycle have passed without incident.
Verification Steps
-- the index is gone, and nothing invalid was left behind
SELECT indexname FROM pg_indexes WHERE indexname = 'idx_parcels_centroid';
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
-- the space was actually reclaimed
SELECT pg_size_pretty(pg_total_relation_size('parcels')) AS table_total;
-- and the queries that might have used it did not regress
SELECT queryid, calls, round(mean_exec_time::numeric, 2) AS mean_ms
FROM pg_stat_statements
WHERE query ILIKE '%parcels%'
ORDER BY total_exec_time DESC
LIMIT 10;Compare that final result against the snapshot taken before the index was hidden. A mean time that moved by more than measurement noise on any statement means the index was contributing after all — and the recreate statement from step zero is what you reach for.
Gotchas Checklist
DROP INDEX CONCURRENTLYcannot be used on an index backing a constraint. PostgreSQL refuses; drop the constraint instead, which drops its index with it.- The hidden-index trick affects the planner immediately but not running statements. Sessions with a cached plan may continue using the index until they re-plan; wait a few minutes before drawing conclusions.
- Do not leave an index hidden indefinitely. It costs full write maintenance and provides nothing, which is the worst of both states — decide within the observation window.
- On a partitioned table, drop the partition indexes. Dropping the parent’s index template detaches and drops the children too, which is usually what you want but is worth being deliberate about.
- Record the drop somewhere durable. Six months later, “why is this query slow on production but not on staging?” is answered by a schema difference nobody remembers creating.
The Paper Trail a Safe Drop Leaves Behind
An index drop that goes wrong is recoverable in principle and painful in practice, and the difference between the two is entirely determined by what was written down beforehand.
Capture four things before the drop runs. The exact definition, from pg_indexes.indexdef, so the recreate is a copy-paste rather than a reconstruction. The size, from pg_relation_size, so the space actually reclaimed can be confirmed afterwards. The scan counts and the statistics reset timestamp, so the justification for the drop is auditable months later. And the plans for the queries you believe touch the table, captured with EXPLAIN and stored in the migration’s pull request.
That last item is the one most often skipped and the one that pays. After the drop, rerunning the same EXPLAIN statements answers the only question that matters — did anything change? — in seconds, and without needing production traffic to reveal it. A plan that switched from an index scan to a bitmap heap scan on a small table is fine. A plan that switched to a sequential scan on a large one is a restore, immediately, using the definition captured in step one.
Two operational details make the drop itself uneventful. Use DROP INDEX CONCURRENTLY, which takes only a SHARE UPDATE EXCLUSIVE lock and lets reads and writes continue; the plain form takes an ACCESS EXCLUSIVE lock and will queue behind — and then block — every query on the table. And run it outside a transaction block, because the concurrent form is not permitted inside one, which is the single most common reason the statement fails in a migration framework that wraps everything in a transaction by default.
If the drop is interrupted, the index is left marked invalid rather than removed. That state is harmless for reads, but it must be cleaned up with a second drop, and it is worth checking for explicitly rather than discovering later.
Related Topics
- Concurrent Index Builds — parent topic: the build side of the same lock story
- Finding Unused Spatial Indexes — identifying the candidates this page removes
- CREATE INDEX CONCURRENTLY on Large Spatial Tables — what a recreate actually costs
- Rolling Back a Failed Spatial Migration — recovering when a drop turns out to have been wrong