PostgreSQL Concurrent Indexes: Waits and Failed Build Recovery
A concurrent index command that is waiting and an invalid index left after failure need different responses. Use phase-aware diagnostics, catalog checks, and the PACE recovery playbook to choose an intervention and verify the intended migration state.
Consider a hypothetical migration retry that reports an existing relation. The migration moves on, but nobody has checked whether the index is usable. PostgreSQL can leave an invalid index after failed concurrent creation; IF NOT EXISTS does not validate the existing definition. The command documentation explains both behaviors.
Start with the backend. Is it still building, or waiting? Establish whether construction has ended before considering recovery. Then check the actual index against the intended migration. A name-exists notice is too weak a completion check.
This guide covers ordinary, non-partitioned application tables. The SQL templates are source-reviewed but unexecuted. Adapt the hypothetical public.orders names and check the database and required privileges. Consult the deployed major version's documentation and rehearse changes in isolation.
What CREATE INDEX CONCURRENTLY changes, and what it does not
Write-compatible does not mean lock-free or wait-free
Ordinary CREATE INDEX blocks table writes while allowing ordinary reads. CREATE INDEX CONCURRENTLY permits normal writes during construction, but still requires coordination and waiting. Only one concurrent build can run on a given table at a time. It must run outside a transaction block. PostgreSQL documents these restrictions.
Two scans, transaction waits, and the validity transition
Concurrent creation registers an invalid catalog entry before performing two table scans in separate transactions. It waits for relevant modifying transactions before the scans and for older snapshots after the second scan. That invalid entry may belong to a healthy build. Leave it alone until you know otherwise. Extra scans and waits also mean concurrent creation can take longer. See the build sequence.
Diagnose PostgreSQL index build waiting by phase and backend
Read pg_stat_progress_create_index before estimating progress
Connect to the target database. This read-only template filters by table, retaining raw identifiers and converting unavailable zero identifiers to null:
SELECT p.pid, p.command, p.phase, p.relid,
NULLIF(p.index_relid, 0) AS index_relid,
p.lockers_total, p.lockers_done,
NULLIF(p.current_locker_pid, 0) AS current_locker_pid,
p.blocks_total, p.blocks_done
FROM pg_stat_progress_create_index AS p
WHERE p.datname = current_database()
AND p.relid = to_regclass('public.orders');A missing target resolves to null and produces no match. Verify the identifier before interpreting an empty result. See the to_regclass documentation.
Interpret counters within the reported phase. PostgreSQL's progress definitions distinguish the following work.
| Reported phase | Next evidence to inspect | Interpretation boundary |
|---|---|---|
waiting for writers before build | Locker counts, current locker, transaction owner | Not a table-scan percentage |
building index and its subphases | Repeated block or tuple counters, resource telemetry | Reporting depends on the access method |
waiting for writers before validation | Relevant writers and their transactions | A later coordination wait, not necessarily failed work |
index validation: scanning index, sorting tuples, or scanning table | Phase name and available counters | Index and table scans measure different work |
waiting for old snapshots | Current locker and snapshot-related activity | Investigate transaction context before intervention |
In the validation row above, "sorting tuples" and "scanning table" also have the full "index validation:" phase prefix. Concurrent reindex can report "waiting for readers before marking dead" and "waiting for readers before dropping"; the same progress reference describes those waits.
Block totals describe the current phase. They cannot establish end-to-end progress, and zero or unavailable totals cannot support a percentage estimate.
Correlate the waiting backend with transaction and lock evidence
Inspect build backends alongside their reported current lockers with this separate read-only template.
WITH progress AS (
SELECT pid, current_locker_pid
FROM pg_stat_progress_create_index
WHERE datname = current_database()
AND relid = to_regclass('public.orders')
), participants AS (
SELECT pid FROM progress
UNION
SELECT current_locker_pid FROM progress
WHERE current_locker_pid > 0
)
SELECT a.pid, a.state, a.xact_start, a.backend_xmin,
a.wait_event_type, a.wait_event,
pg_blocking_pids(a.pid) AS lock_blocking_pids
FROM pg_stat_activity AS a
JOIN participants AS t ON t.pid = a.pid;pg_blocking_pids reports lock blockers but cannot explain every snapshot-related delay. An empty array does not rule out a wait. Likewise, an old xact_start or backend_xmin warrants investigation; it does not identify a session to terminate. Coordinate with the transaction owner. This query includes the build backend and its reported current locker, not a recursive blocker tree. Follow returned blocker PIDs separately. The function can return zero for a prepared-transaction blocker; zero is not a backend to terminate. See the same function reference.
For a comparison outside PostgreSQL, Optijara's TensorRT build-observability guide examines how to distinguish long-running work from failure.
Interpret missing rows and partial visibility cautiously
An absent progress row settles nothing. Check the retained backend PID and client outcome, then correlate server errors with catalog state. Confirm the database selection and monitoring role's permissions. Other sessions' details may be restricted. Long monitoring transactions can also leave observations stale; repeat them outside that transaction. Activity-monitoring documentation explains both limits.
Inspect invalid indexes before retrying with IF NOT EXISTS
Check indisvalid, indisready, indislive, and the actual definition
Inspect the named index and the table it actually belongs to. A migration ledger cannot establish either. The joins below follow the documented relation catalog and namespace catalog.
SELECT ns.nspname AS index_schema, ix.relname AS index_name,
tn.nspname AS table_schema, t.relname AS table_name,
i.indisunique, i.indisvalid, i.indisready, i.indislive,
pg_get_indexdef(i.indexrelid) AS definition
FROM pg_index AS i
JOIN pg_class AS ix ON ix.oid = i.indexrelid
JOIN pg_namespace AS ns ON ns.oid = ix.relnamespace
JOIN pg_class AS t ON t.oid = i.indrelid
JOIN pg_namespace AS tn ON tn.oid = t.relnamespace
WHERE ns.nspname = 'public'
AND ix.relname = 'orders_customer_id_idx';Compare the definition with the approved migration, including uniqueness and access method. Check key order, expressions, included columns, and predicate. pg_get_indexdef reconstructs that definition.
In pg_index, indisvalid describes query validity. indisready tracks readiness for insert/update maintenance; indislive records liveness. A false liveness flag means the index is being dropped and must be ignored for all purposes. An invalid index can still add write overhead. The invalid flag alone does not identify a failed concurrent build.
Unique builds need particular care. Uniqueness enforcement against other transactions begins when the second table scan starts, before validity. A failure during that scan can leave an invalid index that still rejects duplicate writes. The invalid flag also cannot certify that existing data satisfy uniqueness. The concurrent-build caveats explain the timing.
Why name-based idempotency is not a successful migration
IF NOT EXISTS suppresses an error for an existing relation name. It neither rebuilds an invalid index nor guarantees an equivalent definition. Rails issue #52583 reports the resulting timeout/retry trap. That public report does not establish a current Rails fix. PostgreSQL's command reference defines the underlying behavior.
The PACE recovery playbook: Phase, Assess, Choose, Establish readiness
Phase and Assess: capture evidence before changing state
PACE is an editorial framework introduced here. Before changing state, save the original error and current phase. Retain the index identity and definition, along with transaction-owner context.
Before removal, check whether a constraint uses the named index.
SELECT conname, contype, conrelid::regclass AS owning_table
FROM pg_constraint
WHERE conindid = to_regclass('public.orders_customer_id_idx');pg_constraint.conindid identifies supporting indexes. An empty result leaves other dependencies and ownership to investigate. Adding CASCADE is no substitute for that review.
Choose: wait, drop and recreate, or reindex
Choose a branch from established state. An unconditional retry loop skips that decision.
| Established state | Approved next action | Required guard |
|---|---|---|
| Active build with expected progress | Observe | Compare repeated phase-aware evidence |
| Active build waiting | Investigate and coordinate resolution | Confirm the relevant transaction and owner |
| Failed standalone build, intended definition | Fix cause, then drop/recreate or eligible index-level concurrent reindex | Establish that construction ended and recovery is authorized |
| Valid index, mismatched definition | Design a separate migration | Do not treat the existing name as acceptance |
| Failed concurrent reindex artifacts | Follow the documented artifact-specific branch | Inspect identity, dependencies, and surviving valid index |
Use the following unexecuted drop/recreate template only after the failed build has ended and dependency checks permit removal. It assumes the approved definition is exactly a non-unique B-tree on public.orders(customer_id).
DROP INDEX CONCURRENTLY public.orders_customer_id_idx;
CREATE INDEX CONCURRENTLY orders_customer_id_idx
ON public.orders USING btree (customer_id);Submit separate top-level commands outside transaction blocks. If the drop fails, stop and inspect again. CREATE INDEX does not accept a schema-qualified index name; qualifying public.orders places the index in that schema. Check the distinct restrictions for creation and concurrent removal.
For an eligible invalid index whose definition should be preserved, the alternative branch is below.
REINDEX INDEX CONCURRENTLY public.orders_customer_id_idx;Resolve the cause first. Reindexing cannot fix duplicate data or change an incorrect definition, and it does not remove the blocking workload. Target the index explicitly: only the index-level form can perform a concurrent rebuild of an invalid index. Do not substitute a table-wide concurrent reindex. Check the process and exclusions.
A failed concurrent reindex can leave an invalid _ccnew replacement, which the documented branch drops before retrying. An invalid _ccold may instead be the old copy left after a successful rebuild. Numeric suffixes are possible. Verify the surviving valid index and dependencies before approving removal. Never bulk-drop by suffix. PostgreSQL documents the different cleanup paths.
Establish readiness: verify the intended postconditions
After recovery, require command success and repeat the catalog query. Match the schema and table to the intended definition; confirm the valid/ready/live flags. Validity does not force planner selection, and older transactions can delay index usability. Optijara's Kubernetes probe decision guide explores the broader difference between process health and readiness.
Keep an observation record instead of inventing a universal elapsed-time threshold.
| Measurement | Capture point | Decision supported |
|---|---|---|
| Phase and applicable counters | Repeated observations during construction | Advancing work versus investigation needed |
| Locker PID, transaction context, wait events | During a wait | Owner-coordinated resolution |
| Command outcome, definition, catalog flags | Before recovery and after completion | Intended postcondition achieved or not |
| CPU/I/O and application latency/errors | Before, during, and after the operation | Workload impact requiring an operational response |
These are proposed measurements, not benchmark results. The JSON below summarizes the framework and completion rule.
{
"framework": "pace",
"layers": ["phase", "assess", "choose", "establish_readiness"],
"completionRequires": ["successful_command", "intended_definition", "valid_ready_live"],
"automaticDestructiveRetry": false
}What teams get wrong in migration retries
Mistaking a successful retry notice for a valid index
A retry notice answers a narrower question than the migration needs answered. Check the catalog and definition separately. Keep that acceptance check in AI-assisted CI/CD workflows, including migrations proposed by automation.
Retrying before fixing the failed build's cause
Another attempt does not resolve duplicate data or an open transaction. Keep the initial error so recovery addresses the failure you actually observed. Use the following checklist before proceeding.
| Checkpoint | Evidence retained |
|---|---|
| Record intent | Schema, table, complete index definition |
| Configure execution | Concurrent DDL outside enclosing transactions |
| Preserve failure | Original error and command outcome |
| Inspect before retry | Phase, backend, catalog, dependencies |
| Approve recovery | Named owner and eligible branch |
| Accept completion | Successful command and matching valid/ready/live index |
Treating destructive cleanup as generic idempotency
Keep destructive cleanup out of generic retry logic. Require human approval and a fresh state check before acting on sessions or invalid indexes. The concurrent-build and reindex references explain why names and intermediate flags are insufficient grounds for removal.
Caveats: partitions, constraints, monitoring access, and operational cost
Where the ordinary-table playbook stops
The cited documentation does not support concurrent creation on a partitioned parent. Build leaf-partition indexes concurrently, then plan parent-index assembly separately. Do not assume that parent operation is lock-free. PostgreSQL describes this route.
DROP INDEX CONCURRENTLY accepts one index and prohibits CASCADE. It must run outside a transaction block. It excludes partitioned-table indexes and direct removal of indexes supporting UNIQUE or PRIMARY KEY constraints. Check removal eligibility. Concurrent reindex excludes system catalogs and exclusion-constraint indexes. Its eligibility checks are separate.
Why concurrency is a trade-off, not a performance guarantee
Extra scans consume CPU and I/O. Budget engineering time for rehearsal and monitoring, then set an observation window and response plan for the workload. Concurrent-build documentation warns about operational load.
Protect query text and sensitive activity details when sharing incident evidence. Account for monitoring-access restrictions. These templates were not executed against a PostgreSQL fixture in this review. No wait or recovery was observed, and no production or benchmark result is claimed. Rehearse the selected branch before operational use.
Key Takeaways
- 1Read the reported build phase before treating elapsed time or block counters as progress.
- 2An invalid index can belong to an active build; establish failure before authorizing cleanup.
- 3IF NOT EXISTS checks name existence, not definition equivalence or index validity.
- 4Use PACE to connect phase evidence, catalog assessment, approved recovery, and readiness checks.
- 5Concurrent reindex is an eligible-index option, not a repair for duplicate data or a wrong definition.
- 6Verify command completion and the intended valid, ready, live index, while respecting transaction-visibility caveats.
Conclusion
Start with the phase. If the build has failed, establish what remains and fix the cause before choosing an approved recovery path. A retry notice cannot certify the index. Require command success and verify the intended definition and valid/ready/live state, allowing for the documented transaction-visibility limits. If your team wants a second review of its database migration safeguards, Optijara can help assess the plan.
Frequently Asked Questions
Why is CREATE INDEX CONCURRENTLY waiting, and does it avoid all locking?
It permits normal table writes but still coordinates with other transactions. Inspect `pg_stat_progress_create_index.phase`, the build PID, and the reported current locker. An empty `pg_blocking_pids` result does not rule out every wait. See [progress reporting](https://www.postgresql.org/docs/current/progress-reporting.html) and [lock-blocking functions](https://www.postgresql.org/docs/current/functions-info.html).
How do I tell an active build from a failed invalid index?
Correlate progress, backend activity, command errors, and catalog state. Invalid status occurs during construction, so `indisvalid=false` alone does not prove failure or authorize removal. If progress is absent, check database selection, permissions, and backend outcome. See [concurrent-build behavior](https://www.postgresql.org/docs/current/sql-createindex.html) and [monitoring visibility](https://www.postgresql.org/docs/current/monitoring-stats.html).
Will IF NOT EXISTS rebuild an invalid PostgreSQL index?
No. It suppresses an error for an existing relation name without validating equivalence or rebuilding an invalid index. Inspect the definition and catalog state, fix the failure cause, and choose an approved recovery branch. See the [CREATE INDEX reference](https://www.postgresql.org/docs/current/sql-createindex.html).
Can I recover an invalid index without dropping it first?
Yes, `REINDEX INDEX CONCURRENTLY` can rebuild an eligible invalid index with the intended definition. It cannot fix duplicate data, a wrong definition, or the waiting workload. Resolve the cause, check restrictions, and verify postconditions. See [REINDEX](https://www.postgresql.org/docs/current/sql-reindex.html); [drop/recreate](https://www.postgresql.org/docs/current/sql-createindex.html) is another documented option.
Can an invalid unique index still reject duplicate writes?
Yes. Uniqueness enforcement against other transactions begins when the second table scan starts, before validity. If a concurrent unique build fails during the second scan, the invalid index continues enforcing uniqueness afterward. Invalid status does not certify harmlessness or complete data uniqueness. See the [concurrent unique-build caveats](https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY).
Sources
- https://www.postgresql.org/docs/current/sql-createindex.html
- https://www.postgresql.org/docs/current/progress-reporting.html
- https://www.postgresql.org/docs/current/catalog-pg-index.html
- https://www.postgresql.org/docs/current/sql-dropindex.html
- https://www.postgresql.org/docs/current/sql-reindex.html
- https://www.postgresql.org/docs/current/monitoring-stats.html
- https://www.postgresql.org/docs/current/functions-info.html
- https://github.com/rails/rails/issues/52583
- https://www.postgresql.org/docs/current/catalog-pg-constraint.html
- https://www.postgresql.org/docs/current/catalog-pg-class.html
- https://www.postgresql.org/docs/current/catalog-pg-namespace.html
- https://pganalyze.com/blog/postgres-create-index
Written by
Hamza DiazHamza Diaz is the founder of Optijara, where he builds practical AI agents, automation systems, and Copilot workflows for service businesses. He writes about AI operations, agent strategy, and real-world implementation for teams that want usable systems instead of hype.
