feat: use native NOT NULL NOT VALID constraints on PostgreSQL 18+ - #566
Conversation
PostgreSQL 18 supports invalid NOT NULL constraints natively, so the online SET NOT NULL rewrite no longer needs the four-step CHECK constraint dance. On PG18+ targets the plan now emits: ALTER TABLE t ADD CONSTRAINT t_col_not_null NOT NULL col NOT VALID; ALTER TABLE t VALIDATE CONSTRAINT t_col_not_null; That is 2 statements and 1 ACCESS EXCLUSIVE acquisition instead of 4 and 3, with the VALIDATE step still isolated in its own transaction so the scan runs under SHARE UPDATE EXCLUSIVE. New writes are enforced from the moment the NOT VALID constraint is added. The constraint name matches what PostgreSQL auto-generates for a NOT NULL column in CREATE TABLE, so the migrated table converges with a freshly created one. The constraint is invisible to the IR either way: the inspector tracks nullability via attnotnull and skips contype='n' rows, so no dump/diff drift. The target major version is parsed from the current-state IR's database version and threaded through NewPlan into rewrite generation. Pre-18 and unknown versions keep the portable CHECK pattern, covered by new unit tests since integration golden files only run against the latest PostgreSQL version. Verified against live PostgreSQL 18.1: syntax, catalog state (convalidated, attnotnull), NOT VALID enforcement of new rows, and default constraint naming. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Greptile SummaryThe PR gates SET NOT NULL planning on the target PostgreSQL major version and introduces PostgreSQL 18's native NOT NULL NOT VALID sequence while retaining the four-step CHECK fallback for older or unknown versions.
Confidence Score: 4/5The PR should not merge until the PostgreSQL 18 rewrite handles existing constraints that already occupy its generated name. A valid nullable-to-NOT-NULL migration can emit an ADD CONSTRAINT name already retained on the table, causing PostgreSQL to reject the plan before validation. Files Needing Attention: internal/plan/rewrite.go Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Inspect target PostgreSQL version] --> B{Major version at least 18?}
B -->|Yes| C[ADD native NOT NULL constraint NOT VALID]
C --> D[Commit ADD group]
D --> E[VALIDATE CONSTRAINT in isolated group]
B -->|No or unknown| F[ADD temporary CHECK NOT VALID]
F --> G[VALIDATE CHECK in isolated group]
G --> H[SET NOT NULL]
H --> I[DROP temporary CHECK]
Reviews (1): Last reviewed commit: "feat: use native NOT NULL NOT VALID cons..." | Re-trigger Greptile |
There was a problem hiding this comment.
Pull request overview
This PR updates pgschema’s online SET NOT NULL rewrite to take advantage of PostgreSQL 18’s native NOT NULL ... NOT VALID constraints, reducing both statement count and heavyweight lock acquisitions while preserving the existing portable behavior on PostgreSQL 14–17 and on unknown versions.
Changes:
- Thread the target PostgreSQL major version into plan generation (
cmd/plan→internal/plan) to gate version-specific rewrites. - For PG18+, rewrite
SET NOT NULLtoADD CONSTRAINT ... NOT NULL ... NOT VALID+ isolatedVALIDATE CONSTRAINT(instead of the CHECK-constraint 4-step pattern). - Update fixtures, add focused unit tests for both version paths, and document both behaviors.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
cmd/plan/plan.go |
Parses target major version from current-state IR metadata and passes it into plan construction to gate rewrites. |
internal/plan/plan.go |
Threads targetMajorVersion through plan grouping and rewrite generation. |
internal/plan/rewrite.go |
Implements PG18+ native NOT NULL constraint rewrite and preserves pre-18 CHECK-based fallback. |
internal/plan/rewrite_test.go |
Adds unit coverage for PG18/native, PG17 fallback, unknown-version fallback, and quoting. |
internal/plan/plan_test.go |
Updates tests to the new NewPlan signature. |
docs/workflow/online-ddl.mdx |
Documents both PG18+ and PG14–17 NOT NULL strategies and corrects the earlier missing drop step. |
testdata/diff/online/add_not_null/plan.{txt,sql,json} |
Regenerated expected plans for the PG18+ native rewrite path. |
testdata/diff/online/issue_313_camelcase_column_not_null/plan.{txt,sql,json} |
Regenerated expected plans for quoted identifiers under the PG18+ native rewrite path. |
testdata/diff/create_table/add_default_not_null/plan.{txt,sql,json} |
Regenerated expected plans where NOT NULL rewrite interacts with other column changes. |
Suppressed comments (1)
docs/workflow/online-ddl.mdx:122
- In the PG14–17 example, the comment “Add check constraint (non-blocking)” is misleading:
ALTER TABLE ... ADD CONSTRAINT ... NOT VALIDstill needs to acquire anACCESS EXCLUSIVElock (typically briefly). Consider rewording to reflect that it’s fast but may momentarily block while the lock is taken.
On PostgreSQL 14-17, adding `NOT NULL` uses a check constraint based process:
```sql
-- 1. Add check constraint (non-blocking)
ALTER TABLE users ADD CONSTRAINT email_not_null CHECK (email IS NOT NULL) NOT VALID;
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Thank you for a very quick reaction. Maybe one hing:
|
The SET NOT NULL rewrites hardcoded their constraint names (users_email_not_null on PG18+, email_not_null pre-18). If a constraint with that name already exists - typically a leftover CHECK (col IS NOT NULL) from a manual online migration, which the inspector deliberately hides from the IR - ADD CONSTRAINT fails with SQLSTATE 42710 and apply aborts. The pre-18 path had the same latent bug; the PG18 path made it more likely by using PostgreSQL's own naming convention. Record every constraint name present on each table (including rows the inspector otherwise skips: NOT NULL constraints on PG18+, redundant IS NOT NULL checks, ignored constraints) in a new non-serialized Table.AllConstraintNames set, thread the current-state IR into rewrite generation, and pick the first free name with an integer suffix (users_email_not_null1, ...) the way PostgreSQL's ChooseConstraintName does. Reproduced and verified against live PostgreSQL 18.1: the collision scenario that previously failed with 42710 now applies cleanly. Adds a regression fixture (online/not_null_name_collision) and unit tests for both version paths. Reported by greptile on PR #566. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The greptile finding is confirmed and fixed in afae6f9 — reproduced against live PostgreSQL 18.1 before fixing (apply aborted with SQLSTATE 42710). Two things the review surface-level framing missed, for the record:
The fix addresses both paths: the inspector now records every constraint name present on each table (including rows it otherwise skips — PG18 Covered by a new regression fixture ( 🤖 Generated with Claude Code |
|
@jozef-slezak Exactly right — that's the correct catalog signal. It matches what we verified on live 18.1 while building this: after Making the plan self-healing the way you describe (inspector reads 🤖 Generated with Claude Code |
Related to #564.
What
PostgreSQL 18 supports invalid
NOT NULLconstraints natively. On PG18+ targets, the onlineSET NOT NULLrewrite now emits:instead of the four-step CHECK constraint pattern (add check NOT VALID → validate → SET NOT NULL → drop check). That's 2 statements and 1 ACCESS EXCLUSIVE acquisition instead of 4 and 3 — each avoided lock acquisition is one less lock-queue pileup hazard on a busy table. The
VALIDATEstep remains isolated in its own transaction (per #565), so the existing-row scan runs under SHARE UPDATE EXCLUSIVE without blocking traffic. New writes are enforced from the moment the NOT VALID constraint lands.PostgreSQL 14–17 (and unknown versions) keep the portable CHECK pattern unchanged.
How
DatabaseVersionand threaded throughNewPlan→groupDiffs→generateRewrite.<table>_<col>_not_null) matches what PostgreSQL auto-generates for aNOT NULLcolumn inCREATE TABLE, so a migrated table converges with a freshly created one.attnotnulland skipscontype='n'constraint rows entirely.Verified against live PostgreSQL 18.1
convalidatedisfafter ADD,tafter VALIDATEattnotnullis set immediately on ADDCREATE TABLE (... col text NOT NULL)auto-names the constraint<table>_<col>_not_null, matching our chosen nameBehavior note
If apply is interrupted after the ADD but before VALIDATE, the column already reads as NOT NULL in the catalog (
attnotnull = true) while old rows are unvalidated — a re-plan sees no drift and won't re-emit the VALIDATE. This edge is inherent to how PG18 models invalid NOT NULL constraints; new writes are enforced throughout, so the exposure is limited to pre-existing rows.Testing
internal/plan/rewrite_test.gounit tests cover the PG18 native path, the PG17 fallback, the unknown-version fallback, and identifier quoting — important because integration golden files only run against the latest PostgreSQL version, so the pre-18 path has no golden coverage.online/add_not_null,online/issue_313_camelcase_column_not_null,create_table/add_default_not_null); regeneration applies the plans against embedded PG18, verifying the native path end-to-end. All suites re-run clean without--generate.online-ddl.mdxnow documents both version paths (and corrects the old three-step description, which omitted the finalDROP CONSTRAINT).🤖 Generated with Claude Code