1. Paste your migration
One migration file, or several concatenated. Comments, dollar-quoted function bodies and BEGIN/COMMIT blocks are all handled — and the transaction blocks matter, because three of the checks are about what shares a transaction with what.
Paste a migration above and click Check.
Findings
Prefer a CLI, or want this as a CI gate?
Same engine, zero dependencies, no database connection:
node check.mjs # scan every .sql under the current directory
node check.mjs db/migrate # scan one directory or one file
node check.mjs --format json # machine-readable
node check.mjs --list-checks # exactly what is checked, and at what severity
node check.mjs --fail-on-risk # exit 2 if a statement can block the table
Save the three files this page itself runs on — check.mjs, engine.mjs and sql.mjs — into one directory. Node's standard library only, nothing to install, no network calls, no connection string. Wiring --fail-on-risk into CI is the point: it catches the CREATE INDEX that someone added without CONCURRENTLY while the migration is still a pull request, which is the only moment fixing it is free.
What it checks
The whole list — there is nothing hidden behind it. The three marked relational are the ones a per-statement linter cannot make, because the answer depends on another statement in the file.
Common questions
My migration is one line and takes milliseconds. How can it cause an outage?
Because the time a statement takes to run and the time it takes to start are different numbers. An ALTER TABLE needs an ACCESS EXCLUSIVE lock, which conflicts with every other lock — including the ACCESS SHARE that a plain SELECT holds. If any query is still running on that table when your migration arrives, the migration waits. While it waits it sits at the head of the lock queue, and every query arriving after it queues behind it, because PostgreSQL will not let a weaker lock jump ahead of a stronger one that is already waiting. So one slow analytics SELECT, a forgotten open transaction, or a pg_dump is enough to make the table unavailable to everyone for as long as that query runs. SET lock_timeout = '3s' converts that from an outage into a migration that fails and can be retried.
Isn't ADD COLUMN with a DEFAULT safe now?
With a constant default, yes — since PostgreSQL 11 that is a metadata-only change, and this tool does not flag it. The exception is a VOLATILE default, which has to be evaluated separately for every existing row and therefore rewrites the whole table under an exclusive lock. gen_random_uuid() and random() are volatile; now() and CURRENT_TIMESTAMP are STABLE and are not. The two statements look nearly identical, which is exactly why this is worth a checker rather than a habit.
Why does SET NOT NULL get flagged when it changes no data?
Because it has to prove no existing row is null, and it does that with a full table scan while holding an ACCESS EXCLUSIVE lock — so reads and writes both block for the length of the scan. PostgreSQL 12 and later have a fast path: if a validated CHECK constraint already proves the column is not null, the scan is skipped. This tool reads the rest of your file to see whether you took that path, and stays quiet if you did. That is a fact about the file rather than about the statement, which is why a per-statement linter cannot tell the two apart.
Does a clean result mean the migration is safe to run?
No. This checks a specific, named list of statement-level patterns — the whole list is on this page and behind --list-checks, and there is nothing hidden. It cannot see how large your tables are, how much traffic they take, how long your existing queries run, what your migration runner does around the file, whether a backfill in application code is batched, or whether the change is backwards compatible with the version of your app that is still running during a rolling deploy. A clean result means these checks found nothing; it is not a review of your migration.
Does this connect to my database?
No, and it has no way to. It reads SQL text and nothing else — no connection string, no credentials, no driver, no network calls at all. The browser page parses what you paste on your own device, and the CLI reads files from your own disk. That is what makes it safe to run against the migration you are about to apply to production: there is no connection to point at the wrong database.
Which databases does this apply to?
PostgreSQL. The lock levels, the rewrite rules, the NOT VALID / VALIDATE CONSTRAINT split and the CONCURRENTLY forms are all PostgreSQL semantics, and the advice would be wrong in places for MySQL, which has its own online-DDL rules, or SQLite, which rewrites tables for most ALTERs regardless. Running it against a MySQL migration will still flag statements that are risky there too, but read the reasoning rather than the verdict.
More: why your migration locked the table. Already using squawk? See how this checker compares. Just reading the migration by eye? See how this checker compares for the relational checks a single-statement read tends to miss.