no-force-set-not-null
SET NOT NULL without CHECK constraint pre-validation scans the entire table.
- operation
- Columns
- lock taken
- ACCESS EXCLUSIVE
- remediation
- Multi-step plan
- category
- Constraints & keys
- Blocks reads
- Blocks writes
- Scans every row
What triggers it
Fires on an AT_SetNotNull command unless, on PG 12+, an earlier statement in the same migration added a CHECK constraint on that table whose raw SQL text contains both the column name and the phrase "is not null".
What does not
Skips AlterTableStmts with no commands and commands that aren't AT_SetNotNull. On PG 12+ it also stands down when it finds a preceding AT_AddConstraint/CONSTR_CHECK command on the same table whose original SQL text case-insensitively matches both the column name and "is not null" — a raw-text check, not a semantic read of the constraint's expression.
Where it applies
Applies to every PostgreSQL version MigrationPilot targets. It works on the SQL text alone — no database connection needed.
PG 18+ gets a native NOT NULL ... NOT VALID constraint instead of the CHECK-constraint workaround; the preceding-CHECK suppression itself only activates on PG 12+, and below PG 12 the rule falls back to a plain lock_timeout-guarded SET NOT NULL since there is no NOT VALID mechanism for NOT NULL at all pre-12.
The lock, and what it blocks
SET NOT NULL scans every row under ACCESS EXCLUSIVE to confirm none are NULL, so both the lock and the scan it performs are held for the same duration, which grows with table size.
Why it matters
SET NOT NULL requires scanning every row to verify no NULLs exist, all under ACCESS EXCLUSIVE. Pre-validate with a CHECK constraint using NOT VALID, then the SET NOT NULL is instant.
Unsafe, and safe
Flagged
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
Safe alternative
ALTER TABLE users ADD CONSTRAINT chk_email_nn CHECK (email IS NOT NULL) NOT VALID; ALTER TABLE users VALIDATE CONSTRAINT chk_email_nn; ALTER TABLE users ALTER COLUMN email SET NOT NULL;
Deploy and transaction boundaries
Same choreography as MP002: the CHECK/NOT NULL, VALIDATE, and final SET NOT NULL steps need to run as separate statements so the exclusive lock stays brief and the scan happens under the weaker SHARE UPDATE EXCLUSIVE lock.
What it assumes
The preceding-CHECK detection is a text search for the column name plus the literal phrase "is not null" in an earlier statement's raw SQL — a constraint expressing the same rule with different wording (a function call, a CASE expression, different phrasing) won't be recognized, so the rule can still fire even though a validated CHECK already exists.
What backs this rule
Every rule is a claim about PostgreSQL, so it carries what the claim rests on: a handbook chapter that cites the manual, the incidents that put it there, and the version it was last checked against.
ALTER TABLE ... SET NOT NULL has to prove no row violates the constraint.
- verified against
- PostgreSQL 17.10
- last checked
- 2026-08-11
- confidence
- High
PostgreSQL manual
Public incidents and write-ups
What the CLI prints
⚠ [MP018] WARNING (line 1)
SET NOT NULL on "users"."email" scans the entire table under ACCESS EXCLUSIVE lock to verify no NULLs.
Safe alternative:
-- PG 12+ safe approach:
-- Step 1: Add CHECK constraint NOT VALID (instant, no scan)
ALTER TABLE users ADD CONSTRAINT users_email_not_null
CHECK (email IS NOT NULL) NOT VALID;
-- Step 2: Validate separately (SHARE UPDATE EXCLUSIVE, allows reads + writes)
ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null;
-- Step 3: SET NOT NULL is now instant (PG sees the validated CHECK)
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
Why: SET NOT NULL scans every row to verify no NULLs while holding an ACCESS EXCLUSIVE lock. On PostgreSQL 12+, adding a CHECK (col IS NOT NULL) NOT VALID constraint first, validating it, then SET NOT NULL makes the final step instant.
Docs: https://migrationpilot.dev/rules/mp018Generated by running the CLI's own formatter over the flagged example above, so it is the text the tool actually produces. A real run also reports the other rules that fire on the same statement; those blocks are left out here.
Turning it off
For one statement, put a comment on the line before it:
-- migrationpilot-disable MP018 ALTER TABLE users ALTER COLUMN email SET NOT NULL;
For the whole project, in .migrationpilotrc.yml — by name or by id:
rules:
MP018: false
# or keep it, and downgrade it
rules:
MP018:
severity: warningTry it
Open this rule's flagged example in the playground. It runs in your browser — edit it and watch the finding appear and disappear.
Run MP018 in the playground