Summary
t.nodes.constraint() does not set is_enforced. pgsql-deparser@18.x interprets an absent is_enforced as false and emits NOT ENFORCED.
The result is that building a CHECK or FOREIGN KEY constraint with the project's own AST builder, and rendering it with the project's own deparser, produces a constraint that PostgreSQL will never enforce. No error is raised at any stage — the SQL is valid and executes successfully.
This is a disagreement between two first-party packages rather than a bug in either one alone. The deparser's interpretation is correct for parser output; constructed ASTs don't follow the same convention, and the deparser has no way to tell the two apart.
Affected versions
| Package | Version | |
|---|
@pgsql/utils | 18.2.8 | builder omits is_enforced |
pgsql-deparser | 18.0.0 → 18.3.6 | reads absent as NOT ENFORCED |
pgsql-deparser | 17.18.5 | ✅ unaffected |
Present from the first pgsql-deparser@18 release through current latest.
Reproduction
Using only first-party packages, following the pattern in the pgsql-deparser README:
import*astfrom"@pgsql/utils";import{deparseSyncasdeparse}from"pgsql-deparser";conststmt=t.nodes.alterTableStmt({relation: t.nodes.rangeVar({relname: "t",inh: true,relpersistence: "p"}).RangeVar,objtype: "OBJECT_TABLE",cmds: [t.nodes.alterTableCmd({subtype: "AT_AddConstraint",behavior: "DROP_RESTRICT",def: t.nodes.constraint({contype: "CONSTR_CHECK",conname: "x_positive",raw_expr: t.nodes.aExpr({kind: "AEXPR_OP",name: [t.nodes.string({sval: ">"})],lexpr: t.nodes.columnRef({fields: [t.nodes.string({sval: "x"})]}),rexpr: t.nodes.aConst({ival: t.ast.integer({ival: 0})}),}),}),})],});console.log(deparse([{RawStmt: { stmt }}]));Expected:
ALTERTABLE t ADD CONSTRAINT x_positive CHECK (x >0)
Actual:
ALTERTABLE t ADD CONSTRAINT x_positive CHECK (x >0) NOT ENFORCED
The builder output is:
t.nodes.constraint({contype: "CONSTR_CHECK",conname: "c"})// { Constraint: { contype: 'CONSTR_CHECK', conname: 'c' }} <- no is_enforcedPassing is_enforced: true explicitly is preserved by the builder and produces correct SQL.
Scope
Affects the two constraint types PostgreSQL permits NOT ENFORCED on:
CHECK, builder default ALTER TABLE t ADD CONSTRAINT c CHECK (x > 0) NOT ENFORCED ❌
CHECK, is_enforced: true ALTER TABLE t ADD CONSTRAINT c CHECK (x > 0) ✅
FK, builder default ALTER TABLE t ADD CONSTRAINT f FOREIGN KEY(x) REFERENCES o (id) NOT ENFORCED ❌
FK, is_enforced: true ALTER TABLE t ADD CONSTRAINT f FOREIGN KEY(x) REFERENCES o (id) ✅
CONSTR_UNIQUE and CONSTR_PRIMARY are unaffected. Inline constraints in CREATE TABLE are affected the same way as ALTER TABLE ... ADD CONSTRAINT.
skip_validation is not part of the trigger — is_enforced alone determines it. Where skip_validation: true is set, NOT ENFORCEDreplaces the expected NOT VALID rather than accompanying it:
skip_validation | is_enforced | Output |
|---|
| absent | absent | NOT ENFORCED ❌ |
| absent | true | (none) ✅ |
true | absent | NOT ENFORCED ❌ — expected NOT VALID |
true | true | NOT VALID ✅ |
true | false | NOT ENFORCED ✅ |
Why the deparser's logic is not itself wrong
libpg_query omits false booleans from its JSON output, so for parsed ASTs an absent is_enforced genuinely does mean not-enforced. Confirmed with pgsql-parser@18.2.6:
| Parsed SQL | is_enforced | skip_validation |
|---|
CHECK (x > 0) | true | — |
CHECK (x > 0) NOT ENFORCED | undefined | true |
CHECK (x > 0) ENFORCED | true | — |
CHECK (x > 0) NOT VALID | true | true |
So parse() → deparse() round-trips correctly today, including NOT ENFORCED, and the deparser's is_enforced !== true check is right for that path.
Changing the deparser to treat absent as enforced would break this. Verified by applying is_enforced === false to 18.3.6 and re-running round-trips:
✗ in : ALTER TABLE t ADD CONSTRAINT c CHECK (x > 0) NOT ENFORCED
out: ALTER TABLE t ADD CONSTRAINT c CHECK (x > 0) NOT VALID
That silently converts a non-enforced constraint into an enforced one — the same class of failure in the opposite direction. The deparser cannot distinguish "false, omitted by convention" from "not specified by the caller"; both arrive as undefined.
Why this matters
NOT ENFORCED (PG18) means the constraint applies to neither existing nor new rows. Both plausible caller intents are silently violated:
- A bare CHECK or FK constraint should be fully enforced. It is inert instead.
NOT VALID should enforce for new rows while deferring validation of existing ones — the standard pattern for adding a constraint to a large table. It is inert instead, and the intended clause is dropped.
Nothing surfaces the problem: the SQL is valid, applies cleanly, and enforces nothing. For a migration tool this means shipped constraints that never take effect, discoverable only by inspecting the database afterwards.
This is reachable through the documented path — the pgsql-deparser README's primary example constructs an AST with @pgsql/utils and notes it "could have been obtained from any JSON or AST."
Suggested fix
Preferred: default is_enforced: true in the @pgsql/utils constraint builder for CONSTR_CHECK and CONSTR_FOREIGN. This aligns the builder with the serialization convention the deparser already assumes, fixes existing callers with no code change on their side, and leaves the deparser's parser-output handling untouched. It's clear that this is a large, invasive change given how pgsql/utils is built, so it's understandable if you don't want to pursue it.
Alternatives, if that isn't desirable:
- Add a Typescript type constraint that requires an explicit decision in the builder signature for
CONSTR_CHECK and CONSTR_FOREIGN. This would be a breaking change for consumers (though probably fixing a bug in their construction). I can provide a PR for this if you're interested. - Add a deparser option (e.g.
deparse(ast, { assumeEnforced: true })) for callers constructing ASTs rather than round-tripping. - Document in
packages/deparser/README.md under ## Options, and as TSDoc on Constraint.is_enforced, that omitting the field on a constructed AST yields NOT ENFORCED.
Documentation alone would leave every consumer needing to know an implicit serialization convention in order to avoid emitting non-enforcing constraints, so a default in the builder seems the more robust resolution.
Summary
t.nodes.constraint()does not setis_enforced.pgsql-deparser@18.xinterprets an absentis_enforcedasfalseand emitsNOT ENFORCED.The result is that building a CHECK or FOREIGN KEY constraint with the project's own AST builder, and rendering it with the project's own deparser, produces a constraint that PostgreSQL will never enforce. No error is raised at any stage — the SQL is valid and executes successfully.
This is a disagreement between two first-party packages rather than a bug in either one alone. The deparser's interpretation is correct for parser output; constructed ASTs don't follow the same convention, and the deparser has no way to tell the two apart.
Affected versions
@pgsql/utilsis_enforcedpgsql-deparserNOT ENFORCEDpgsql-deparserPresent from the first
pgsql-deparser@18release through currentlatest.Reproduction
Using only first-party packages, following the pattern in the
pgsql-deparserREADME:Expected:
Actual:
The builder output is:
Passing
is_enforced: trueexplicitly is preserved by the builder and produces correct SQL.Scope
Affects the two constraint types PostgreSQL permits
NOT ENFORCEDon:CONSTR_UNIQUEandCONSTR_PRIMARYare unaffected. Inline constraints inCREATE TABLEare affected the same way asALTER TABLE ... ADD CONSTRAINT.skip_validationis not part of the trigger —is_enforcedalone determines it. Whereskip_validation: trueis set,NOT ENFORCEDreplaces the expectedNOT VALIDrather than accompanying it:skip_validationis_enforcedNOT ENFORCED❌truetrueNOT ENFORCED❌ — expectedNOT VALIDtruetrueNOT VALID✅truefalseNOT ENFORCED✅Why the deparser's logic is not itself wrong
libpg_queryomits false booleans from its JSON output, so for parsed ASTs an absentis_enforcedgenuinely does mean not-enforced. Confirmed withpgsql-parser@18.2.6:is_enforcedskip_validationCHECK (x > 0)trueCHECK (x > 0) NOT ENFORCEDundefinedtrueCHECK (x > 0) ENFORCEDtrueCHECK (x > 0) NOT VALIDtruetrueSo
parse()→deparse()round-trips correctly today, includingNOT ENFORCED, and the deparser'sis_enforced !== truecheck is right for that path.Changing the deparser to treat absent as enforced would break this. Verified by applying
is_enforced === falseto 18.3.6 and re-running round-trips:That silently converts a non-enforced constraint into an enforced one — the same class of failure in the opposite direction. The deparser cannot distinguish "false, omitted by convention" from "not specified by the caller"; both arrive as
undefined.Why this matters
NOT ENFORCED(PG18) means the constraint applies to neither existing nor new rows. Both plausible caller intents are silently violated:NOT VALIDshould enforce for new rows while deferring validation of existing ones — the standard pattern for adding a constraint to a large table. It is inert instead, and the intended clause is dropped.Nothing surfaces the problem: the SQL is valid, applies cleanly, and enforces nothing. For a migration tool this means shipped constraints that never take effect, discoverable only by inspecting the database afterwards.
This is reachable through the documented path — the
pgsql-deparserREADME's primary example constructs an AST with@pgsql/utilsand notes it "could have been obtained from any JSON or AST."Suggested fix
Preferred: default
is_enforced: truein the@pgsql/utilsconstraint builder forCONSTR_CHECKandCONSTR_FOREIGN. This aligns the builder with the serialization convention the deparser already assumes, fixes existing callers with no code change on their side, and leaves the deparser's parser-output handling untouched. It's clear that this is a large, invasive change given howpgsql/utilsis built, so it's understandable if you don't want to pursue it.Alternatives, if that isn't desirable:
CONSTR_CHECKandCONSTR_FOREIGN. This would be a breaking change for consumers (though probably fixing a bug in their construction). I can provide a PR for this if you're interested.deparse(ast, { assumeEnforced: true })) for callers constructing ASTs rather than round-tripping.packages/deparser/README.mdunder## Options, and as TSDoc onConstraint.is_enforced, that omitting the field on a constructed AST yieldsNOT ENFORCED.Documentation alone would leave every consumer needing to know an implicit serialization convention in order to avoid emitting non-enforcing constraints, so a default in the builder seems the more robust resolution.