Check Constraint Add Violated By Some Row
A CREATE/ALTER migration or a hand-run ALTER TABLE ... ADD CONSTRAINT ... CHECK (...) aborts with SQLSTATE 23514 and the message 'check constraint...
In 10 seconds
A schema migration adds a business rule as a CHECK constraint -- here CHECK (quantity > 0) on a line_items table -- and fails immediately with 'ERROR: check constraint "line_items_qty_pos" of relation "line_items" is violated by some row' (SQLSTATE 23514). The table already holds rows that break the rule: a bug wrote five line items with quantity 0. ADD CONSTRAINT validates every existing row before it records the constraint, so the first zero-quantity row aborts the whole ALTER and the constraint is never created. The migration looks broken, but the constraint is doing exactly its job -- it found dirty data you did not know was there. This is the fourth member of the add-a-constraint-to-dirty-data family (foreign key with orphans, unique index with duplicates, NOT NULL with nulls); the shape is identical and only the kind of dirt and the cleanup differ.
Why This Happens
ADD CONSTRAINT for a CHECK is validating: before the constraint is recorded, PostgreSQL scans the whole table (ATRewriteTable) and tests every row against the predicate under an ACCESS EXCLUSIVE lock. The first row that fails raises 23514 and rolls the ALTER back, so the constraint is never half-applied. The error message intentionally does not name a row, because the failure is a property of the existing data set, not of one statement -- which is why the fix is to clean the data, not to change the DDL.
Once the table is clean the same ADD CONSTRAINT succeeds and the constraint becomes convalidated (fully trusted), after which any future rule-breaking INSERT or UPDATE is rejected at write time with the second 23514 form. Once the table is clean the same ADD CONSTRAINT succeeds and the constraint becomes convalidated (fully trusted), after which any future rule-breaking INSERT or UPDATE is rejected at write time with the second 23514 form.
Diagnose
Free Tier -- Basic Approach
Before touching anything, measure the blast radius with one count: how many rows break the rule the CHECK would enforce. Five. That single number tells you the cleanup is tiny and bounded -- not an unknown.
SELECT count(*) AS bad_rows FROM line_items WHERE NOT (quantity > 0);
bad_rows
----------
5
(1 row)
SELECT count(*) AS bad_rows FROM line_items WHERE NOT (quantity > 0); -- reuse the exact predicate from the constraint so the count matches what ADD CONSTRAINT will reject.
Fix
Make the data obey the rule, then add the constraint. The five zero-quantity rows are bug artifacts with no business value, so they are deleted (DELETE 5); if the violators carried real meaning you would UPDATE them instead. With the table clean, the same ADD CONSTRAINT that failed now succeeds (ALTER TABLE).
DELETE FROM line_items WHERE NOT (quantity > 0); ALTER TABLE line_items ADD CONSTRAINT line_items_qty_pos CHECK (quantity > 0);
DELETE 5 ALTER TABLE
DELETE FROM line_items WHERE NOT (quantity > 0); then ALTER TABLE line_items ADD CONSTRAINT line_items_qty_pos CHECK (quantity > 0); -- on a large hot table, prefer ADD CONSTRAINT ... NOT VALID followed by VALIDATE CONSTRAINT to avoid holding ACCESS EXCLUSIVE for the full scan.
Verify
Confirm the constraint is present and validated, that zero rows break the rule, and that the constraint now enforces itself at write time. The rejected insert raises the OTHER 23514 form -- 'new row for relation ... violates check constraint ...' with DETAIL: Failing row contains (1001, ord-x, 0) -- which is the run-time enforcement you wanted all along.
SELECT conname, convalidated FROM pg_constraint WHERE conrelid = 'line_items'::regclass AND contype = 'c';
conname | convalidated --------------------+-------------- line_items_qty_pos | t (1 row)
SELECT count(*) AS bad_rows FROM line_items WHERE NOT (quantity > 0);
bad_rows
----------
0
(1 row)
INSERT INTO line_items(order_ref, quantity) VALUES ('ord-x', 0);
ERROR: new row for relation "line_items" violates check constraint "line_items_qty_pos" DETAIL: Failing row contains (1001, ord-x, 0).
Check pg_constraint for conname/convalidated = t, re-run the count(*) WHERE NOT (quantity > 0) (expect 0), and attempt INSERT ... VALUES ('ord-x', 0) to confirm it is rejected with SQLSTATE 23514 and a Failing row DETAIL.
⏪ Rollback
Nothing to roll back from the failed ADD CONSTRAINT itself -- the aborted ALTER leaves the table and its data exactly as they were, with no constraint added. The recovery is forward: count the rule-breaking rows, then correct them with an UPDATE (when the value carries real meaning) or remove them with a DELETE (when they are bug artifacts with no business value), and re-run the ADD CONSTRAINT. The only destructive choice is in the cleanup step (DELETE vs UPDATE), which is why the premium evidence -- the exact rows and how extreme the values are -- should be reviewed before you choose.
📦 Version Matrix (PG 14--18)
The same failure-then-clean-fix is identical on PostgreSQL 14 through 18: ADD CONSTRAINT against dirty data reports 'is violated by some row', and after removing the violators the same ADD CONSTRAINT returns ALTER TABLE. The 23514 behavior of validating ADD CONSTRAINT is stable across all supported majors.
| Version | Behavior | Version Notes |
|---|---|---|
PG 14.23 |
broken=is violated by some row | ✗ Not available -- use stats_reset age |
PG 15.18 |
broken=is violated by some row | ✗ Not available -- use stats_reset age |
PG 16.14 |
broken=is violated by some row | ✗ Not available -- use stats_reset age |
PG 17.10 |
broken=is violated by some row | ✗ Not available -- use stats_reset age |
PG 18.4 |
broken=is violated by some row | ✗ Not available -- use stats_reset age |
⚠ Common Mistakes
Stop this incident before it pages you
The free runbook clears today's failure. Pro adds the standing guardrails -- the alert query, the dashboard, and the managed-service knobs -- so it never becomes an incident again.
- ✓ A ready-to-paste alert query with the right thresholds.
- ✓ The topology change that prevents recurrence.
- ✓ The exact knobs on RDS, Azure Flexible Server & Cloud SQL.
Frequently Asked Questions
Why does the error not say which row is bad?
Because ADD CONSTRAINT is validating the entire existing data set, not a single write. The 23514 raised from ATRewriteTable reports the constraint and table only. Find the offenders yourself with SELECT ... WHERE NOT (quantity > 0). The other 23514 form -- a failing INSERT/UPDATE after the constraint exists -- does include DETAIL: Failing row contains (...).
Should I DELETE or UPDATE the violating rows?
It depends on whether the bad value carries meaning. Here quantity 0 is a bug artifact with no business value, so the rows are deleted. If the violators were, say, negative amounts that represent real refunds, you would correct them with an UPDATE (or widen the rule) instead. Read the premium row dump and the min/zero/negative breakdown before deciding.
Can I add the constraint without locking a big table for the whole scan?
Yes. Clean the data, then ADD CONSTRAINT ... CHECK (...) NOT VALID (instant; it immediately enforces new writes) and afterward run VALIDATE CONSTRAINT, which scans existing rows under a weaker SHARE UPDATE EXCLUSIVE lock that does not block reads and writes. If any old rows still violate the rule, VALIDATE raises the same 23514, so the data must be clean first.
Once it is fixed, does the constraint stop future bad data?
Yes. After the clean ADD CONSTRAINT the constraint is convalidated and enforced on every write. A rule-breaking INSERT (here quantity 0) is rejected with 'new row for relation ... violates check constraint ...' and DETAIL: Failing row contains (...). Seeing that run-time error after the migration is proof the guard is working.
How do I keep this from recurring?
Declare the CHECK in the CREATE TABLE so violating rows can never accumulate, fix the application/import path that produced them, and keep a standing guard query (SELECT count(*) WHERE NOT (predicate)) in monitoring -- it must always return 0.