Cleaning Up INVALID Indexes After a Failed Concurrent Build

You kicked off a CREATE INDEX CONCURRENTLY on a hundred-million-row orders table, the deploy timed out, and someone hit Ctrl-C — or the migration tool’s statement_timeout fired, or a deadlock killed the session mid-build. The command reported failure, the deploy rolled back, and everyone moved on. A day later write latency on that table is up ten percent for no obvious reason, and \d orders in psql shows an index you thought never got created, quietly annotated INVALID. That annotation is the whole story: PostgreSQL’s concurrent build is not atomic, so when its second pass dies it leaves a half-built index in place rather than rolling it back. The planner refuses to read from it because it may be incomplete, but the storage engine still maintains it on every INSERT, UPDATE, and DELETE. You are paying the full write cost of an index and getting none of its read benefit. This page is the cleanup: how to detect the state with pg_index.indisvalid, drop the dead index without blocking writers, and retry the build so it completes cleanly this time.

The good news is that an invalid index is inert as far as query results go — dropping it changes no answer any query returns, so the cleanup itself is safe to run at any hour. The recovery is a specific instance of the broader discipline in Online Index Management: build additively, verify before you trust, and remove nothing until you have proven it is safe to remove. Here the thing to remove has already proven itself safe — it was never valid to begin with.

Symptom / Error Signatures

The first signature is the one that opened this page: a CREATE INDEX CONCURRENTLY that did not report clean success. When the build is cancelled or times out you see the interrupt directly:

ERROR:  canceling statement due to user request

or, when a lock budget or statement budget fired instead:

ERROR:  canceling statement due to statement timeout
ERROR:  canceling statement due to lock timeout
ERROR:  deadlock detected

Any of these on a concurrent build is your cue to go looking for a leftover. The clinching signature comes from psql’s \d on the table, where the index appears flagged:

Indexes:
    "idx_orders_customer_created" btree (customer_id, created_at) INVALID

The authoritative check is the catalog itself. pg_index.indisvalid = false (often with indisready = false as well) is the definitive marker that a concurrent build did not finish:

-- PostgreSQL · run against the PRIMARY as any read role · read-only, safe anytime.
-- Context: any row returned is a failed-build leftover taxing writes while serving no reads.
SELECT c.relname AS index_name,
       t.relname AS table_name,
       i.indisvalid,
       i.indisready,
       pg_size_pretty(pg_relation_size(c.oid)) AS index_size
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_class t ON t.oid = i.indrelid
WHERE NOT i.indisvalid;

If that query returns any row, you have confirmed the problem: an index exists physically, occupies disk, is maintained on every write, and the planner will never choose it. Two subtler tells reinforce it — a duplicate-looking index name with a _ccnew suffix (left by a failed REINDEX INDEX CONCURRENTLY, which builds a shadow index before swapping), and write latency on the table that regressed without any corresponding read-path improvement, because the write tax landed but the read benefit never did.

From a cancelled build to an INVALID catalog flag A cancelled CREATE INDEX CONCURRENTLY leaves an index that psql shows as INVALID and that pg_index reports with indisvalid equals false. CREATE INDEX CONCURRENTLY cancelled mid-build psql › \d orders Indexes: idx_orders_customer_created btree (customer_id, created_at) INVALID planner ignores it, writers still maintain it pg_index indisvalid = f indisready = f
The interrupted build is not rolled back: the index persists on disk, appears INVALID in \d, and is confirmed by pg_index.indisvalid = false — the authoritative marker of a failed concurrent build.

Root Cause Analysis

A plain CREATE INDEX runs as a single atomic operation under a heavy lock: it either completes or rolls back, and a rollback leaves nothing behind. CREATE INDEX CONCURRENTLY (CIC) trades that atomicity for availability. To avoid blocking writes it splits the work into multiple transactions — an initial catalog entry marking the index indisready = false, indisvalid = false, a first table scan that builds the index from a snapshot, a wait for every transaction older than the build to finish, and a second scan that reconciles the rows written during the first. Only after the second scan succeeds does PostgreSQL flip indisvalid to true. Because those phases live in separate transactions, an interruption between them cannot be rolled back as a unit — the catalog row and the partially-populated index files are already committed. PostgreSQL deliberately leaves them in place rather than guessing, marking the index invalid so the planner ignores it while the storage engine keeps maintaining it for correctness (if it ever were validated, its entries must be current). The full mechanics of the two-pass build are covered in building indexes with CREATE INDEX CONCURRENTLY.

This is the sharpest divergence between the two engines, and it is worth stating side by side because the cleanup you owe depends entirely on which one you are on:

Aspect PostgreSQL (CREATE INDEX CONCURRENTLY) MySQL 8.0 InnoDB (ALGORITHM=INPLACE, LOCK=NONE)
Atomicity on failure Not atomic — multi-transaction build Atomic — single logical operation
State left after a killed build INVALID index that persists None — build rolls back cleanly
Write cost of the leftover Full index maintenance on every write Zero — nothing remains
Cleanup required Yes — explicit DROP INDEX CONCURRENTLY No cleanup needed; just retry the ALTER
How to detect pg_index.indisvalid = false Not applicable — no leftover to detect
REINDEX failure artifact Transient _ccnew-suffixed invalid index Not applicable

The practical upshot: MySQL engineers reading this page after a failed online DDL have nothing to clean — a failed ALTER TABLE ... ALGORITHM=INPLACE, LOCK=NONE unwinds itself, and the fix is to address the cause (usually a too-small innodb_online_alter_log_max_size or a blocking transaction) and rerun. PostgreSQL engineers own a real cleanup, because the invalid index will sit there taxing writes indefinitely until someone drops it. It never self-repairs; there is no background process that retries or removes it.

Failed-build aftermath: PostgreSQL leaves an INVALID index, MySQL leaves nothing A killed concurrent build in PostgreSQL freezes at a committed INVALID index that persists, while a failed MySQL online DDL rolls back atomically to no index. PostgreSQL CIC catalog entry indisvalid = f first scan builds index second scan killed INVALID index persists MySQL 8.0 InnoDB INPLACE build LOCK = NONE fails mid-build atomic rollback no index left clean
Same interruption, opposite aftermath: PostgreSQL's multi-transaction build freezes at a committed INVALID index you must drop, while MySQL's atomic online DDL unwinds to nothing — only the PostgreSQL path owes a cleanup.

Immediate Mitigation

The goal is to remove the write tax now and restore a working index. Run these against the primary as a role that owns the index (or a superuser); the drop must run outside a transaction block just like the build did.

1. Confirm the invalid index exists and capture its exact definition. You need the definition to rebuild it correctly after dropping it — do not skip this and lose the column list.

-- PostgreSQL · run against the PRIMARY as any read role · read-only, safe anytime.
-- Context: records the exact DDL so the retry rebuilds the same index; note the INVALID ones.
SELECT indexrelid::regclass AS index_name,
       indrelid::regclass  AS table_name,
       pg_get_indexdef(indexrelid) AS definition
FROM pg_index
WHERE NOT indisvalid;

Verify before proceeding: you have the index name and its full CREATE INDEX definition recorded. If the name carries a _ccnew suffix, it is a failed REINDEX shadow — dropping it is still correct, and the original index it was rebuilding is untouched and still valid.

2. Drop the invalid index concurrently so the cleanup itself never blocks. A plain DROP INDEX takes an ACCESS EXCLUSIVE lock that queues behind in-flight queries; the concurrent form avoids that.

-- PostgreSQL · run as the index owner · MUST run OUTSIDE a transaction block (no BEGIN/COMMIT).
-- Context: safe anytime — an INVALID index is never read, so dropping it changes no query result.
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_customer_created;

Verify before proceeding: re-run the detection query from the Symptom section and confirm it returns zero rows. Write latency on the table should begin to recover as the maintenance overhead disappears.

3. Resolve the cause before you retry, or you will land right back here. The most common causes are a statement_timeout too short for the build, a lock_timeout firing on the brief metadata lock, or a long-running transaction that stalled the wait phase until something killed the session. Check for the last one first.

-- PostgreSQL · run against the PRIMARY as a monitoring role · read-only, safe anytime.
-- Context: any transaction older than a few minutes will stall the next CIC's wait phase — resolve it first.
SELECT pid, state, now() - xact_start AS txn_age, query
FROM pg_stat_activity
WHERE state <> 'idle' AND xact_start IS NOT NULL
ORDER BY txn_age DESC
LIMIT 10;

Verify before proceeding: the oldest transaction is seconds old, not minutes, and your migration session’s statement_timeout is either disabled or generously above the expected build duration for this table size.

4. Retry the build outside a transaction, with a lock budget but no statement budget. Rebuild from the definition you captured in step 1. Run it in its own connection with autocommit on — most migration tools need an explicit flag (executeInTransaction=false in Flyway, disable_ddl_transaction! in Rails).

-- PostgreSQL · run as the table owner · MUST run OUTSIDE a transaction block.
-- Context: no statement_timeout on this session; lock_timeout bounds only the brief metadata lock.
SET lock_timeout = '2s';
SET statement_timeout = 0;
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_customer_created
  ON orders (customer_id, created_at);

Verify before proceeding: the statement returns without error and pg_index.indisvalid is now true for the index (the check in the Verification Checklist confirms this). If it fails again, drop the new invalid leftover and diagnose before a third attempt.

Four-step recovery timeline ending at a valid index Detect the invalid index, drop it concurrently, clear the blocking transaction, retry CREATE INDEX CONCURRENTLY, ending with indisvalid equal to true. 1 Detect indisvalid = false 2 Drop DROP INDEX CONCURRENTLY 3 Clear end blocking transaction 4 Retry CREATE INDEX CONCURRENTLY Result: pg_index.indisvalid = true — write tax gone, index serving reads
The cleanup is a fixed sequence: confirm the leftover, drop it without blocking, remove whatever stalled the last attempt, then rebuild — landing on a validated index rather than another invalid one.

Permanent Fix / Long-Term Pattern

The durable fix is to stop failed builds from silently accumulating in the first place, which is a matter of how the build is invoked, not how it is cleaned up. Three habits close the gap. First, never wrap a concurrent build in a statement_timeout inherited from ordinary migrations — an index build on a large table legitimately runs for many minutes, and a short timeout guarantees an invalid leftover every time. Set SET statement_timeout = 0 for the build’s session while keeping a small lock_timeout so only the brief metadata lock fails fast. Second, gate every deploy on a catalog scan for invalid indexes, so a leftover is caught by the pipeline instead of by a latency alert days later:

-- PostgreSQL · CI/CD post-migration gate · run against the PRIMARY as a read role · read-only.
-- Context: fail the deploy if any invalid index survives a migration step — catches leftovers immediately.
SELECT indexrelid::regclass AS invalid_index
FROM pg_index
WHERE NOT indisvalid;

Wire that into the pipeline so a non-empty result fails the build. Third, make the build idempotent and self-cleaning: because a concurrent build cannot resume a partial one, a re-runnable migration should drop any invalid index of the target name before rebuilding, so a retry starts clean. That guard is a direct application of idempotent script design to index DDL:

-- PostgreSQL · migration step · MUST run OUTSIDE a transaction block.
-- Context: drops a prior failed leftover (if any) before rebuilding, so the step is safely re-runnable.
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_customer_created;
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_customer_created
  ON orders (customer_id, created_at);

The deeper preventive context — running the build outside a transaction, sizing the lock budget, and watching progress through pg_stat_progress_create_index so you catch a stalled wait phase before it gets killed — lives in the parent Online Index Management guide and its companion on building indexes with CREATE INDEX CONCURRENTLY. Treating the invalid-index scan as a standing deploy gate, rather than a thing you remember to check, is what turns this from a recurring surprise into a non-event.

Post-migration deploy gate on invalid indexes After a migration step runs, a catalog scan checks pg_index for indisvalid equals false; any leftover fails the deploy while zero rows lets it proceed. migration step runs scan pg_index WHERE NOT indisvalid rows > 0 leftover found FAIL the deploy zero rows clean catalog proceed
Wiring the invalid-index scan into the pipeline turns a leftover into an immediate deploy failure instead of a latency alert days later — any non-empty result stops the release.

Verification Checklist

Up one level: Online Index Management.

Frequently Asked Questions

Can I just re-run CREATE INDEX CONCURRENTLY to fix an invalid index? No — the retry will fail or, worse, silently create a second index. If you use the same name, CREATE INDEX CONCURRENTLY IF NOT EXISTS sees the invalid index already exists and skips the build entirely, leaving the invalid one in place; without IF NOT EXISTS it errors on the name collision. A concurrent build cannot resume or repair a partial one. You must DROP INDEX CONCURRENTLY the invalid index first, then build fresh. That is exactly why a re-runnable migration should drop the leftover before rebuilding.

Is it safe to drop an invalid index while the application is live? Yes. An invalid index is never used to answer a query — the planner ignores it because it may be incomplete — so dropping it cannot change any result the application sees. Use DROP INDEX CONCURRENTLY so the drop itself takes only a light lock and does not block writers, and the operation is safe at any traffic level. The only thing you remove is the write-maintenance overhead the invalid index was costing you, which is the outcome you want.

Why does MySQL not leave an invalid index after a failed online build? Because InnoDB’s online DDL is atomic where PostgreSQL’s concurrent build is not. A failed ALTER TABLE ... ALGORITHM=INPLACE, LOCK=NONE rolls back as a unit, leaving no partial index and nothing to clean up — you simply address the cause and rerun the ALTER. PostgreSQL’s CREATE INDEX CONCURRENTLY splits its work across multiple committed transactions to avoid blocking writes, so an interruption cannot be unwound and the half-built index is deliberately left behind marked indisvalid = false. The availability guarantee is what costs you the cleanup.