Migration Guard › demo

See it work

A sample migration and its table sizes go in; Migration Guard ranks the risky statements by how much data each one locks.

Input

SQLmigration.sql
CREATE INDEX idx_orders_customer ON orders (customer_id);
ALTER TABLE orders ADD COLUMN gift_note text;
ALTER TABLE feature_flags ALTER COLUMN rollout TYPE numeric(5,2);
ALTER TABLE customers ADD CONSTRAINT customers_email_key UNIQUE (email);
CREATE TABLE refund_reasons (id bigint PRIMARY KEY, label text NOT NULL);
COMMENT ON TABLE refund_reasons IS 'reasons shown in the refund form';
TextTable sizes (from pg_class)
  table_name   | estimated_rows | total_bytes
---------------+----------------+-------------
 orders        |       48213664 | 12240512000
 customers     |         310442 |   164626432
 feature_flags |            212 |       49152
(3 rows)

Output

1 to fix before merge

6 statements read. Without table sizes, these 4 findings look alike. With your sizes: 1 to fix before merge, 1 apply at any size, 2 low impact at current size.

Tables of 1,024 MB or more count as large.

  1. Fix before merge orders · ~48.2M rows · 11.4 GB

    require-concurrent-index-creation, line 1: reads the whole table (11.4 GB including indexes) while holding SHARE (writes wait)

    Statement, lock, rewrite and docs
    Statement
    SQL
    CREATE INDEX idx_orders_customer ON orders (customer_id)
    Lock takenSHARE
    Blockswrites (INSERT, UPDATE, DELETE); reads still work
    WhyCREATE INDEX without CONCURRENTLY takes a SHARE lock on the table for the whole index build. Inserts, updates and deletes wait until it finishes, so a large table means a long write outage.
    Lower-risk rewriteCREATE INDEX CONCURRENTLY builds the index without blocking writes (lower risk because it only takes SHARE UPDATE EXCLUSIVE). It cannot run inside a transaction block, so run it outside BEGIN / COMMIT. If it fails it leaves an INVALID index that you must drop and retry.
    PostgreSQL docssql-createindex.html
    explicit-locking.html#LOCKING-TABLES
  2. Applies at any size orders · ~48.2M rows · 11.4 GB

    require-lock-timeout, line 2: The lock is brief once granted. The risk is waiting for it: while it waits, every later query on the table queues behind it.

    Statement, lock, rewrite and docs
    Statement
    SQL
    ALTER TABLE orders ADD COLUMN gift_note text
    Lock takenACCESS EXCLUSIVE
    Blocksreads and writes (SELECT, INSERT, UPDATE, DELETE)
    WhyAn ACCESS EXCLUSIVE statement that has to wait for a lock queues in front of every later query on that table, so one slow transaction can stall all reads and writes. No SET lock_timeout appears before this statement.
    Lower-risk rewritePut SET lock_timeout = '5s'; at the top of the migration (lower risk because the statement gives up and can be retried instead of queueing indefinitely).
    PostgreSQL docsruntime-config-client.html#GUC-LOCK-TIMEOUT
    explicit-locking.html#LOCKING-TABLES
  3. Low impact at current size customers · ~310.4K rows · 157.0 MB

    disallowed-unique-constraint, line 4: reads the whole table (157.0 MB including indexes) while holding ACCESS EXCLUSIVE (reads and writes wait)

    Statement, lock, rewrite and docs
    Statement
    SQL
    ALTER TABLE customers ADD CONSTRAINT customers_email_key UNIQUE (email)
    Lock takenACCESS EXCLUSIVE
    Blocksreads and writes (SELECT, INSERT, UPDATE, DELETE)
    WhyADD UNIQUE or ADD PRIMARY KEY builds its index while holding an ACCESS EXCLUSIVE lock, so reads and writes wait for the whole build.
    Lower-risk rewriteCREATE UNIQUE INDEX CONCURRENTLY first (outside a transaction block), then ALTER TABLE ... ADD CONSTRAINT ... UNIQUE USING INDEX (or PRIMARY KEY USING INDEX). Lower risk because the attach step needs no index build.
    PostgreSQL docssql-altertable.html
    sql-createindex.html
    explicit-locking.html#LOCKING-TABLES
  4. Low impact at current size feature_flags · ~212 rows · 48.0 KB

    changing-column-type, line 3: usually rewrites all 48.0 KB while holding ACCESS EXCLUSIVE (reads and writes wait)

    Statement, lock, rewrite and docs
    Statement
    SQL
    ALTER TABLE feature_flags ALTER COLUMN rollout TYPE numeric(5,2)
    Lock takenACCESS EXCLUSIVE
    Blocksreads and writes (SELECT, INSERT, UPDATE, DELETE)
    WhyALTER COLUMN TYPE takes an ACCESS EXCLUSIVE lock and usually rewrites the table and its indexes. Binary-coercible changes (for example widening varchar) skip the rewrite, but the lock is still taken.
    Lower-risk rewriteExpand and contract: add a new column of the new type, write to both, backfill in batches, switch reads, then drop the old column. Lower risk because no single step rewrites the table under an ACCESS EXCLUSIVE lock.
    PostgreSQL docssql-altertable.html
    explicit-locking.html#LOCKING-TABLES

What Migration Guard did

  • Migration Guard: Matched each statement to the lock it takes and what that lock blocks

    By hand: look up every statement in the PostgreSQL lock docs

  • Migration Guard: Looked up the size of every table the migration touches

    By hand: query pg_class for each table

  • Migration Guard: Ranked the findings by how much data each one locks

    By hand: judge which ones matter, and hope you did not miss one

  • Migration Guard: Gives the same answer on every run

    By hand: an AI review can answer differently from one run to the next

Want this on every pull request?

The first 100 on the waitlist get $9/repo/mo, locked for as long as you stay subscribed. Then it's $19/repo/mo.

We store your email, a source tag, your consent and the signup time in Cloudflare D1, to email you about Migration Guard. Privacy

BVL privacy policy

Try it on your own migration

Paste a PostgreSQL migration and, if you like, your table sizes. Rules assume PostgreSQL 12 or later. Nothing you paste is stored.

Show the query to run

Run this on your database to add table sizes. It reads table statistics from the Postgres catalog and returns no row data.

SQL
SELECT c.oid::regclass AS table_name,
       c.reltuples::bigint AS estimated_rows,
       pg_total_relation_size(c.oid) AS total_bytes
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'p')
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY total_bytes DESC
LIMIT 500;
Settings

Limits. Checks the statement types listed below; other statements are shown as not analysed. No findings is not a guarantee. Row counts are Postgres estimates.

Statement types checked