Skip to content
Migration Safety Engine

Catch exclusive locks before they take checkout down.

The flagship module. A fresh branch carrying production's shape applies the pending migrations while a second connection samples what is locked, then reports the strongest mode held per table, how long it was held, whether another session was left waiting on it, which tables were rewritten, and how the query plans moved.

migration rehearsal · lock exposure
COMPARE
ALTER · subscriptions.plan_id

rehearsal branch · observer connection at 250ms

FAIL
FindingObserved
Strongest lockACCESS EXCLUSIVE
Lock duration27.4 seconds
Blocking observedtrue
Table rewriteyes
Plan changeIndex Scan -> Seq Scan
Held
27.4s
Rewrite
Yes
Blocking
true
migration rehearsal · lock exposure

An exclusive lock on subscriptions holds for 27 seconds. The rehearsal reports it before it ships.

The finding

A 27-second lock is a finding. Not a line in a log nobody reads.

  • ACCESS EXCLUSIVE

    the strongest mode held on subscriptions, sampled every 250ms

    27.4s
  • Blocked another session

    a second session was seen waiting on the lock while it was held

    Yes
  • Table rewrite

    reported by Postgres, not inferred from the statement

    Yes
  • Plan change

    EXPLAIN before and after, on production's own shape

    Seq Scan
postgres · concurrent observation
EVIDENCE
subscriptions migration

three connections · one measured lock window

FAIL
Observed viaResult
MigrationALTER COLUMN plan_id TYPE bigint
LockACCESS EXCLUSIVE held for 27.4 seconds
Blocking observedtrue
Lock finding
second session waited on the relation lock
true
postgres · concurrent observation

Example findingA rehearsal of one migration, with the numbers chosen. The measurements are the ones af insights takes: lock mode and hold time from pg_locks, rewrites from Postgres, plans from EXPLAIN.

Measured, not inferred. The lock comes from pg_locks and the rewrite from Postgres itself.

Staging with a handful of rows will not show an exclusive lock or a table rewrite. A sampler on its own connection watches pg_locks and pg_stat_activity while the migration runs, because the session running it cannot see its own lock until the statement returns, which is exactly when the interesting part is over.

Suggested remediation
Add a second column of the new type, backfill it in batches, deploy code that reads both, then drop the old column in a later migration.
af insights · evidence provenance
MEASURED
one report, three observed sources

no timing inferred from SQL text

FAIL
1pg_locks
lock mode + hold
ACCESS EXCLUSIVE · 27.4s
2pg_stat_activity
contention
another session waiting
3Postgres + EXPLAIN
rewrite + plan
rewrite · Index → Seq Scan
internal/insightslint finding
20260824_widen_plan_id

lock        ACCESS EXCLUSIVE  subscriptions  27.4s
blocked     another session was seen waiting on it
rewrite     subscriptions rewritten in full
plan        events: Index Scan -> Seq Scan  12ms -> 410ms
lint        changing plan_id to bigint rewrites the whole table
Migration lint
direct type change rewrites subscriptions
Remedy
expand, backfill in batches, dual-read, contract later
af insights · evidence provenance

Failures conventional tests miss. The engine measures what staging cannot.

  • Locks

    The strongest mode held per table, how long, and whether another session was left waiting on it.

  • Rewrites

    Full table rewrites, reported by Postgres rather than guessed from the SQL.

  • Plans

    EXPLAIN before and against the migrated branch, on production's own shape.

  • Statements

    Per-statement duration, so the slow one in a batch is named.

  • Lint

    Missing lock timeouts, constraints added without NOT VALID, index builds that are not concurrent, backfills sharing a transaction with the schema change, and the rewrites and offline table operations. Each finding reaches the pull request under its own rule name with the fix attached, because a finding called migration_lint tells nobody what to change.

  • Comparison

    A saved report from an earlier run, compared against this one.

Safer pattern: expand-and-contract. The lint rule carries the fix, not only the complaint.

Switch the film to expand-and-contract. The strongest lock drops to 0.4s, nothing is left waiting on it, and the table is not rewritten.

What the lint rule says
Changing a column to bigint rewrites the whole table under an ACCESS EXCLUSIVE lock, so nothing can read it either. Add a new column of the new type, backfill it, switch reads and writes over, then drop the old one.
subscriptions · compatibility window
SAFE PATH
expand-and-contract sequence

schema stays readable across deploys

PASS
  1. 01
    Expand
    add nullable plan_id_v2
    0.4s lock
  2. 02
    Backfill
    copy values in batches
    no rewrite
  3. 03
    Dual-read
    read both schema shapes
    compatible
  4. 04
    Contract
    drop old column later
    later deploy
WindowOld binaryNew binary
Both columnsreads plan_idreads both
After contractretiredreads plan_id_v2
Strongest hold
0.4s
Blocking
false
Rewrite
No
subscriptions · compatibility window
  1. Expand

    Add a second column of the new type, nullable. Old rows stay readable by both binaries.

  2. Backfill

    Copy values across in batches so checkout never waits on ACCESS EXCLUSIVE.

  3. Dual-read

    Deploy code that reads both shapes before you tighten the schema.

  4. Contract

    Drop the old column in a later migration, once nothing reads it any more.

Next

Know what happens before you deploy.

Create a disposable production twin for every risky change. Catch migration failures before they reach customers.