The symptom
One of these usually brings you here:
\d tableshows an index markedINVALID, and queries aren't using it- A migration running
CREATE INDEX CONCURRENTLYhas been running for hours with no progress - A
REINDEX CONCURRENTLYfailed and left an index ending in_ccnewor_ccold - Writes fail with
duplicate key value violates unique constraint, naming an index the application doesn't use yet
A concurrent build can't roll back cleanly, because it commits in stages. If it fails or is cancelled, the index stays in the catalog. Queries ignore an invalid index, but writes may still have to maintain it, and an invalid unique index can keep rejecting rows.
1. List invalid indexes
Run this in each database you suspect. pg_index is per database.
SELECT n.nspname AS schema_name,
t.relname AS table_name,
c.relname AS index_name,
c.relkind,
i.indisunique,
i.indisready,
i.indislive,
pg_size_pretty(pg_relation_size(c.oid)) AS index_size,
pg_get_indexdef(i.indexrelid) AS definition
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_class t ON t.oid = i.indrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE NOT i.indisvalid
ORDER BY pg_relation_size(c.oid) DESC;
Save the definition column. You need it for the retry.
2. Check whether a build is still running
Caution
A build in progress also shows as invalid. Don't drop anything from the list until you've run this check. If a build is still active, let it finish or cancel it first.
SELECT p.pid,
p.relid::regclass AS table_name,
p.index_relid::regclass AS index_name,
p.command,
p.phase,
p.lockers_total,
p.lockers_done,
p.current_locker_pid,
p.blocks_done,
p.blocks_total,
p.tuples_done,
p.tuples_total
FROM pg_stat_progress_create_index p;
If the index appears here, go to step 3. If it doesn't, the build is dead and you can go to step 4.
A partitioned parent index (relkind = 'I') can also be invalid on purpose. It stays invalid until every partition has an attached index, so don't count it as a failure. Step 6 covers this.
3. Unstick a waiting build
The progress numbers tell you what the build is doing:
| Phase | Meaning | Action |
|---|---|---|
building index, index validation: * | Doing real work; blocks or tuples are rising | Wait; check disk space and I/O |
waiting for writers before build or before validation | Waiting for transactions holding locks on the table | Find current_locker_pid |
waiting for old snapshots | Waiting for older transactions, which may not touch this table at all | Find current_locker_pid |
waiting for readers before marking dead or before dropping | REINDEX CONCURRENTLY only; waiting for sessions using the old index | Find current_locker_pid |
Look up the blocker:
SELECT pid, usename, application_name, state,
now() - xact_start AS xact_age,
wait_event_type, wait_event,
left(query, 100) AS last_query
FROM pg_stat_activity
WHERE pid = (SELECT current_locker_pid
FROM pg_stat_progress_create_index
WHERE pid = 12345); -- pid of the build
The usual culprit is a session in idle in transaction, or a long report. Ask the owner to finish it. If that isn't possible, end the session:
SELECT pg_terminate_backend(67890); -- the blocker, not the build
Warning
pg_terminate_backendrolls back the blocker's open transaction. Confirm what it is first.pg_cancel_backendwon't help on anidle in transactionsession, because there's no running statement to cancel.
The next locker may then appear. Run the query again until the phase moves on.
4. Find out why it failed
Search the PostgreSQL log for the index name, or read the migration's output.
| Error | Cause | Fix before retrying |
|---|---|---|
could not create unique index with Key (...) is duplicated | Duplicates in the data | Clean up the duplicates |
deadlock detected | Another session's lock order clashed with the build | Retry at a quieter time |
canceling statement due to statement timeout or lock timeout | A session or role timeout applied to the build | Set both to 0 for the build session |
canceling statement due to user request or terminating connection | Someone cancelled it, or the session or a pooler dropped | Run it directly from tmux or screen |
No space left on device | Temp files or the index outgrew the disk | Free space, or move temp_tablespaces |
For a unique index, find the duplicates:
SELECT email, count(*)
FROM app.users
GROUP BY email
HAVING count(*) > 1
ORDER BY count(*) DESC
LIMIT 20;
Warning
An invalid unique index can keep enforcing uniqueness after the failure. New duplicates may get
duplicate keyerrors even though the index isn't usable for queries. If production writes are failing, drop it now and clean up afterwards.
5. Clean up and retry
Leftovers from CREATE INDEX CONCURRENTLY
DROP INDEX CONCURRENTLY IF EXISTS sales.idx_orders_customer;
DROP INDEX CONCURRENTLY can't run in a transaction block and doesn't support CASCADE.
Then retry from a direct connection, not through a transaction-pooling proxy:
SET statement_timeout = 0;
SET lock_timeout = 0;
SET maintenance_work_mem = '1GB'; -- size this to the host's free memory
CREATE INDEX CONCURRENTLY idx_orders_customer ON sales.orders (customer_id);
REINDEX INDEX CONCURRENTLY sales.idx_orders_customer; also rebuilds an invalid index in place, and keeps the name and definition. If the cause was duplicates, it fails the same way until you've cleaned up the data. To target an invalid index this way, use REINDEX INDEX. Table- and schema-level forms skip invalid indexes when running concurrently.
Leftovers from REINDEX CONCURRENTLY
The suffix tells you what to do. A number may follow it, such as _ccnew1.
| Suffix | What it is | Action |
|---|---|---|
_ccnew | The new copy; the rebuild didn't finish | Drop it and rerun REINDEX CONCURRENTLY |
_ccold | The original index; the rebuild worked but the drop didn't | Drop it; no rerun needed |
DROP INDEX CONCURRENTLY sales.idx_orders_customer_ccnew;
REINDEX INDEX CONCURRENTLY sales.idx_orders_customer;
Verify
SELECT indexrelid::regclass, indisvalid, indisready, indislive
FROM pg_index
WHERE indexrelid = 'sales.idx_orders_customer'::regclass;
All three must be t.
6. Partitioned tables
CREATE INDEX CONCURRENTLY isn't supported on a partitioned parent. Build each partition's index separately and attach it:
-- Creates an invalid parent index, without touching the partitions
CREATE INDEX idx_orders_customer ON ONLY sales.orders (customer_id);
-- Repeat for each partition
CREATE INDEX CONCURRENTLY idx_orders_2026_10_customer
ON sales.orders_2026_10 (customer_id);
ALTER INDEX sales.idx_orders_customer
ATTACH PARTITION sales.idx_orders_2026_10_customer;
The parent becomes valid automatically once every partition has an attached index. If a partition build fails, clean up that one child index with the steps above, and confirm it's valid before attaching it.
Replicas
- Physical standbys replay the catalog changes, so they show the same invalid index. Run all the commands on the primary; the drop reaches the standbys through WAL.
- Logical subscribers don't receive DDL. Check for and fix invalid indexes on each subscriber separately.
Prevent it next time
- Don't use
IF NOT EXISTSon a concurrent build without checking the result. If an invalid index already has that name, the command skips it with a notice and reports success, and your migration passes with an unusable index. - Add a validity check after each concurrent build in your migrations.
- Run concurrent builds outside a transaction block, or they fail with
CREATE INDEX CONCURRENTLY cannot run inside a transaction block. Most migration tools need a per-migration setting for this, so check yours. - Cap idle transactions, so one forgotten session can't hold up a build:
ALTER ROLE app_user SET idle_in_transaction_session_timeout = '5min';
- Monitor for leftovers on every database with this query. It skips active builds and partitioned parents:
SELECT count(*) AS invalid_indexes
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE NOT i.indisvalid
AND c.relkind <> 'I'
AND NOT EXISTS (SELECT 1
FROM pg_stat_progress_create_index p
WHERE p.index_relid = i.indexrelid);