Cookbook/ Schema & Constraints/ Check Constraint Add Violated By Some...
Schema & Constraints · Runbook

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...

🧩 5 steps ⏱ ~15 min 📊 Intermediate 🐘 PostgreSQL 14--18 Last reviewed June 26, 2026

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

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.

SQL
SELECT count(*) AS bad_rows FROM line_items WHERE NOT (quantity > 0);
OUTPUT -- captured on real PostgreSQL
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.

This is a Pro lesson

Get every Learning Pathway and cookbook recipe -- grounded in PostgreSQL source code, with diagnostics, fixes, and prevention for each topic.

Continue this lesson to learn:

  • Advanced Approach -- Production-Safe
  • All 36 Learning Pathway lessons
  • 170+ cookbook recipes
  • Source-grounded diagnostics & fixes

Secure checkout Cancel anytime Source-grounded

FIX

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).

SQL
DELETE FROM line_items WHERE NOT (quantity > 0);
ALTER TABLE line_items ADD CONSTRAINT line_items_qty_pos CHECK (quantity > 0);
OUTPUT -- captured on real PostgreSQL
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

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.

SQL
SELECT conname, convalidated FROM pg_constraint
WHERE conrelid = 'line_items'::regclass AND contype = 'c';
OUTPUT -- captured on real PostgreSQL
conname       | convalidated
--------------------+--------------
 line_items_qty_pos | t
(1 row)
SQL
SELECT count(*) AS bad_rows FROM line_items WHERE NOT (quantity > 0);
OUTPUT -- captured on real PostgreSQL
bad_rows
----------
        0
(1 row)
SQL
INSERT INTO line_items(order_ref, quantity) VALUES ('ord-x', 0);
OUTPUT -- captured on real PostgreSQL
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

Treating 23514 at ADD time as a DDL bug and trying to force the constraint -- there is no NOT VALID escape that skips the bad data permanently; NOT VALID only defers the scan, and a later VALIDATE CONSTRAINT will raise the same 23514 until the rows are fixed.
Blindly DELETEing every violating row. Zero-quantity bug artifacts are safe to delete, but negative amounts might be refunds and out-of-range values might be real -- correct them with an UPDATE instead of destroying data. Always read the premium row dump first.
Forgetting to fix the source of the bad rows. Cleaning existing data lets the constraint apply, but if the application or import path that wrote quantity 0 is still running, new violations arrive the moment the lock is released (now correctly rejected with the run-time 23514, which can surface as application errors).
Running the validating ADD CONSTRAINT on a large hot table during business hours -- it holds ACCESS EXCLUSIVE for the full scan. On big tables add the constraint as ADD CONSTRAINT ... CHECK (...) NOT VALID (instant, enforces new writes) and then VALIDATE CONSTRAINT under a weaker SHARE UPDATE EXCLUSIVE lock.
Assuming the error tells you which row failed. The ADD-time 23514 names only the constraint and table; you must SELECT the rows WHERE NOT (predicate) yourself. Only the write-time 23514 includes 'Failing row contains (...)'.
🔒 Pro · Prevent

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.
Unlock Pro$24.99/mo · $199/yr

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.

References

Keep going

Related & next steps