Problem Statement
A migration failed at 03:12. The deploy is halted, the application is running the previous version, and somebody has to decide within a few minutes whether to re-run, roll back or escalate. This page is the decision procedure — what a failed spatial migration can actually leave behind, how to tell which case you are in, and what to do about each. It complements the zero-downtime migration patterns that aim to prevent the situation.
Why the Naive Approach Fails
The instinctive response is to re-run the migration, and it is right about half the time. The other half it turns a recoverable situation into a confusing one: a second CREATE INDEX CONCURRENTLY fails on the duplicate name left by the first, a re-run backfill re-processes rows it already handled, and an ALTER TABLE that partially applied leaves a schema that matches neither the old nor the new model.
The opposite instinct — restore from backup — is equally reflexive and far more expensive. On a spatial database of any size, a restore is hours, and most failed migrations do not need one.
Production-Ready Implementation
The triage query — run this first, before deciding anything:
-- 1. anything still running?
SELECT pid, state, now() - xact_start AS running, left(query, 80) AS query
FROM pg_stat_activity
WHERE application_name LIKE '%migration%' OR query ILIKE '%CONCURRENTLY%';
-- 2. invalid indexes: the classic concurrent-DDL leftover
SELECT indexrelid::regclass AS invalid_index, indrelid::regclass AS on_table
FROM pg_index WHERE NOT indisvalid;
-- 3. constraints added but never validated
SELECT conrelid::regclass AS table_name, conname
FROM pg_constraint WHERE NOT convalidated AND contype = 'c';
-- 4. partitions detached but not re-attached or dropped
SELECT c.relname
FROM pg_class c
WHERE c.relkind = 'r'
AND c.relname LIKE 'vehicle_positions_%'
AND NOT EXISTS (SELECT 1 FROM pg_inherits i WHERE i.inhrelid = c.oid);
-- 5. how far did the backfill get?
SELECT count(*) FILTER (WHERE geom IS NULL) AS remaining,
count(*) FILTER (WHERE geom IS NOT NULL) AS done
FROM listings;Five queries, thirty seconds, and the state is no longer a mystery. Each maps to a specific action:
# recovery.py — one function per leftover, all idempotent
import psycopg
def drop_invalid_indexes(dsn: str) -> list[str]:
"""Remove indexes left INVALID by an interrupted concurrent build."""
dropped = []
with psycopg.connect(dsn, autocommit=True) as conn, conn.cursor() as cur:
cur.execute("""
SELECT indexrelid::regclass::text
FROM pg_index WHERE NOT indisvalid
""")
for (name,) in cur.fetchall():
cur.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {name}")
dropped.append(name)
return dropped
def validate_pending_constraints(dsn: str) -> list[str]:
"""Finish constraints added NOT VALID whose validation did not run."""
done = []
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
cur.execute("""
SELECT conrelid::regclass::text, conname
FROM pg_constraint WHERE NOT convalidated AND contype = 'c'
""")
for table, name in cur.fetchall():
cur.execute(f"ALTER TABLE {table} VALIDATE CONSTRAINT {name}")
conn.commit()
done.append(f"{table}.{name}")
return doneBoth functions are safe to run when there is nothing to do, which is what makes them usable at three in the morning by someone who has not read this page.
Configuration and Tuning Knobs
lock_timeout and statement_timeout should differ. A short lock timeout makes a blocked DDL fail fast and harmlessly. A long statement timeout lets a legitimate index build finish. Setting one value for both guarantees one of the two behaves wrongly.
log_min_duration_statement = 0 for the migration session produces a complete record of what ran and how long it took. On a migration that fails, that log is the difference between knowing where it stopped and guessing.
A dedicated application_name makes the triage query above actually work. Set it in the migration runner and every subsequent investigation gets easier.
Verification Steps
After any recovery, verify the schema matches the model rather than assuming:
alembic current # which revision does the database claim to be at?
alembic check # does the model agree with the database?-- and the spatial specifics
SELECT count(*) FROM pg_index WHERE NOT indisvalid; -- expect 0
SELECT count(*) FROM pg_constraint WHERE NOT convalidated; -- expect 0
SELECT count(*) FROM listings WHERE geom IS NULL; -- expect the known numberThe third query needs a known expected value, which is the argument for recording one before the migration starts. “How many rows should legitimately be null?” is a question with a definite answer beforehand and a very uncertain one afterwards.
Gotchas Checklist
- Alembic’s version table may disagree with reality. A revision that failed after its DDL but before the version insert leaves the schema changed and the version unchanged.
alembic stampis how you reconcile, once you have verified which state the schema is actually in. - Do not re-run a non-idempotent backfill. Without a watermark, a second run re-processes rows, which is harmless for a pure transform and destructive for anything cumulative.
DROP INDEX CONCURRENTLYalso cannot run inside a transaction. The cleanup script needs autocommit, exactly like the build did.- A detached partition is invisible to the parent but still holds data. Re-attaching it is usually the right recovery; dropping it is not, and the difference is a lot of rows.
- Write down what you did. A recovery performed at 3 a.m. and not recorded becomes an unexplained schema difference three months later.
Rollback or Fix Forward: Deciding Under Pressure
The decision is not a matter of taste, and it is easier if the criteria are agreed before the incident rather than during it.
Roll back when the migration has not yet written data in a format only the new code understands. A migration that added a column, created an index, or installed a constraint is trivially reversible, and reversing it restores a state that is known to work. Roll back also when the failure is not understood: an unexplained error during a schema change is a reason to return to the last known-good state and investigate from there, not to add a second change on top of the first.
Fix forward when reversing would itself destroy data. A completed reprojection that overwrote geometries in place cannot be undone by dropping a column, because the original coordinates are gone; the recovery path is a corrected transform applied to the current values, or a restore from backup. Fix forward also when the migration is partially applied across a partitioned table and the rollback would have to walk the same partitions the failure just interrupted — that is the same amount of risk in the less-tested direction.
The time budget is the tiebreaker. If the rollback takes ten minutes and the fix is not yet written, roll back. If the rollback takes three hours because it rebuilds an index and the fix is a one-line correction that has already been reviewed, fix forward. Estimating both durations honestly, in advance, is what makes the choice fast when it has to be.
Whichever path is taken, the application must survive both states. A deploy that requires the new column to exist turns a schema rollback into an outage, which is why the column-add and the code that reads it belong in separate releases.
Related Topics
- Backfilling and Zero-Downtime Migrations — parent topic: the patterns that keep this page unnecessary
- Writing Reversible Geometry Migrations — the docstring that tells you whether option two is available
- Concurrent Index Builds — where invalid indexes come from
- Spatial Schema Migrations & Evolution — the parent section on live schema change