Skip to main content
forge lint --migration-safety reads every *.up.sql file under db/migrations/ and flags SQL that is fine on an empty test database and wrong on a populated one, plus the version-numbering mistakes that make a migration silently never run. It is also what forge ci migration-safety and the migration-safety row of task ci run. Forge migrations only roll forward, so the cost of a bad one is a new forward migration written in a hurry. Read the overview first for the flags, the output and how to silence a rule. The Severity column is the default; the first four rules have a dial in forge.yaml:
Rollout cells follow the rollout page: v0.1.40 means the rule has gated since that release or earlier; v1.0.0 -> v1.0.1 means it warned in the first and gates from the second.

Rules

Data-safety rules

destructive-change

A migration that drops or rewrites data cannot be undone by running another migration: the data is gone. The rule asks you to say so on purpose, and the fix is usually to spread the change over two releases.

unsafe-add-not-null-column

Postgres refuses ADD COLUMN … NOT NULL with no default on a table that already has rows. It works on the empty database your tests use and fails in production. This is the real finding from a scratch project:
For a derived column, let the database compute it: ADD COLUMN total BIGINT GENERATED ALWAYS AS (qty * price) STORED NOT NULL is accepted. If you know the table is empty, put -- forge:allow-unsafe-not-null in the file. That pragma is separate from forge:allow-destructive on purpose, so one does not exempt the other.

set-not-null-without-backfill

The second half of the same hazard: tightening an existing column to NOT NULL fails if any row is null.

volatile-default

ADD COLUMN … DEFAULT now() (also current_timestamp, clock_timestamp(), gen_random_uuid(), random()) is evaluated once per existing row and rewrites the table under an ACCESS EXCLUSIVE lock. It does not apply to CREATE TABLE, which has no rows yet.

Version rules

Forge records one integer per applied migration and applies only what is above it. That makes version mistakes the worst kind: nothing errors, and the schema reports current.

no-down-migration

Forge rolls forward only and never runs down SQL, so a .down.sql is dead code that looks like a safety net.
Recover from a bad migration with a new forward migration.

duplicate-migration-version

Two files with the same version leave the schema unable to say which one ran. Whichever reaches a shared database first leaves the other permanently unapplied.

non-timestamp-migration-version

Sequential numbers collide: every branch cut from one commit reads the same highest number and picks the same next one. A UTC timestamp cannot collide unless two branches allocate in the same second.

migration-version-below-merged-head

A new migration numbered at or below the highest version already on the default branch is invisible: databases that ran the newer migration never apply it. CI passes because a fresh test database applies everything in version order. The rule compares against the branch point, which is why a reviewer cannot spot it in a diff.

Transaction rules

A migration file reaches Postgres as one query, which Postgres runs as one implicit transaction. Two kinds of statement break that.

non-transactional-statement

CREATE INDEX CONCURRENTLY, DROP INDEX CONCURRENTLY, REINDEX … CONCURRENTLY, VACUUM and similar cannot run inside a transaction block, and a file with several statements runs as one.
If Postgres really does run your file outside a transaction, mark it -- forge:allow-in-transaction: <why>.

enum-value-used-before-commit

A value added with ALTER TYPE … ADD VALUE cannot be used until the transaction that added it commits.

Declarations

invalid-adopts-directive

-- forge:adopts <tool> <version> <version> … on a squashed baseline tells the migrator which history the baseline replaces. The migrator refuses every database while a malformed or misplaced declaration exists, and a baseline whose adoption does not take effect would run its SQL against a database another tool already migrated.

migration-compat-undeclared, migration-compat-reason, migration-compat-conflict

With require_compat_declaration: true, every migration after the baseline says whether the previous release keeps working on the schema it leaves. That is what rolling back or running two versions side by side depends on. Without the setting, only the conflict rule applies.
Use -- forge:breaks-rollback <why> for a dropped or renamed column old code reads, or a NOT NULL column old code does not write.