Cookbook/ Schema & Constraints/ Exclusion Constraint Conflicts With Existing Rows
Schema & Constraints · Runbook

Exclusion Constraint Conflicts With Existing Rows

Two related failures, both SQLSTATE 23P01. At schema-change time, ALTER TABLE ... ADD CONSTRAINT ... EXCLUDE ... fails with 'could not create exclusion...

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

In 10 seconds

You want to make double-booking impossible: no two reservations for the same room may have overlapping time ranges. The right tool is an exclusion constraint -- EXCLUDE USING gist (room_id WITH =, during WITH &&). But ALTER TABLE ... ADD CONSTRAINT fails immediately with 'could not create exclusion constraint', because the table already contains overlapping bookings that slipped in before the rule existed. PostgreSQL will not create a constraint that the current data violates. You must find and resolve the existing overlaps first, then add the constraint -- which from then on rejects any overlapping insert at write time.

Why This Happens

An exclusion constraint generalizes UNIQUE: instead of forbidding two rows that are equal, it forbids two rows that are 'true' together under a set of operators -- here equal room_id (=) and overlapping ranges (&&), backed by a gist index (btree_gist supplies gist support for the scalar room_id). Creating the constraint builds that index and checks every existing row against every other; if any pair satisfies all the operators it is a violation, so the create aborts with 23P01. The data predates the rule, so the overlaps are already there -- exactly like trying to add UNIQUE to a column that already has duplicates.

DIAGNOSE

Diagnose

Free Tier -- Basic Approach

Count the overlapping pairs that block the constraint with a self-join on the same room and an overlapping range.

SQL
SELECT count(*) AS overlapping_pairs
FROM room_bookings a JOIN room_bookings b
  ON a.room_id = b.room_id AND a.id < b.id AND a.during && b.during;
OUTPUT -- captured on real PostgreSQL
overlapping_pairs
-------------------
                 5
(1 row)

SELECT count(*) AS overlapping_pairs FROM room_bookings a JOIN room_bookings b ON a.room_id = b.room_id AND a.id < b.id AND a.during && b.during. Five pairs overlap -- the exact number of conflicts you must resolve before the EXCLUDE constraint can be created.

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

Resolve the conflicts, then add the constraint. Here the overlapping additions are the later booking of each pair; deleting them leaves one clean booking per room so the ADD CONSTRAINT validates and succeeds.

SQL
DELETE FROM room_bookings WHERE id IN (
  SELECT b.id FROM room_bookings a JOIN room_bookings b
    ON a.room_id = b.room_id AND a.id < b.id AND a.during && b.during
);
ALTER TABLE room_bookings ADD CONSTRAINT no_overlap EXCLUDE USING gist (room_id WITH =, during WITH &&);
OUTPUT -- captured on real PostgreSQL
DELETE 5
ALTER TABLE

DELETE FROM room_bookings WHERE id IN (SELECT b.id FROM room_bookings a JOIN room_bookings b ON a.room_id = b.room_id AND a.id < b.id AND a.during && b.during) removes the 5 later bookings (DELETE 5). Then ALTER TABLE room_bookings ADD CONSTRAINT no_overlap EXCLUDE USING gist (room_id WITH =, during WITH &&) completes (ALTER TABLE) because no overlapping pair remains.

VERIFY

Verify

Confirm the constraint exists, an overlapping insert is rejected with 23P01, and a non-overlapping insert is accepted -- proving the rule blocks exactly the double-bookings and nothing else.

SQL
SELECT conname, contype FROM pg_constraint WHERE conrelid = 'room_bookings'::regclass AND contype = 'x';
OUTPUT -- captured on real PostgreSQL
conname   | contype
------------+---------
 no_overlap | x
(1 row)
SQL
INSERT INTO room_bookings(room_id, during) VALUES (1, tsrange(TIMESTAMP '2025-01-01 02:30', TIMESTAMP '2025-01-01 03:30'));
OUTPUT -- captured on real PostgreSQL
ERROR:  23P01: conflicting key value violates exclusion constraint "no_overlap"
DETAIL:  Key (room_id, during)=(1, ["2025-01-01 02:30:00","2025-01-01 03:30:00")) conflicts with existing key (room_id, during)=(1, ["2025-01-01 02:00:00","2025-01-01 03:00:00")).
SCHEMA NAME:  public
TABLE NAME:  room_bookings
CONSTRAINT NAME:  no_overlap
LOCATION:  check_exclusion_or_unique_constraint, execIndexing.c:874
SQL
INSERT INTO room_bookings(room_id, during) VALUES (1, tsrange(TIMESTAMP '2025-06-01 00:00', TIMESTAMP '2025-06-01 01:00'));
OUTPUT -- captured on real PostgreSQL
INSERT 0 1

SELECT conname, contype FROM pg_constraint WHERE conrelid = 'room_bookings'::regclass AND contype = 'x' shows no_overlap with contype x. Inserting an overlapping booking for room 1 fails with 'conflicting key value violates exclusion constraint', while a non-overlapping booking for room 1 succeeds (INSERT 0 1).

⏪ Rollback

The failed ADD CONSTRAINT is atomic -- it leaves no half-built constraint or index, so there is nothing to clean up after the error. The fix deletes the overlapping additions; if you need them back, restore from backup before re-running (the recipe deletes the later booking of each conflicting pair). Dropping the constraint afterward is ALTER TABLE room_bookings DROP CONSTRAINT no_overlap, which also removes its backing index. No other table is touched.

📦 Version Matrix (PG 14--18)

Try the create-over-conflicts and the resolve-then-create on PostgreSQL 14 through 18. Every version refuses the constraint while overlaps exist and accepts it once they are gone.

Version Behavior Version Notes
PG 14.23 broken=could not create exclusion constraint ✗ Not available -- use stats_reset age
PG 15.18 broken=could not create exclusion constraint ✗ Not available -- use stats_reset age
PG 16.14 broken=could not create exclusion constraint ✗ Not available -- use stats_reset age
PG 17.10 broken=could not create exclusion constraint ✗ Not available -- use stats_reset age
PG 18.4 broken=could not create exclusion constraint ✗ Not available -- use stats_reset age

Common Mistakes

Forgetting CREATE EXTENSION btree_gist makes the create fail for a different reason ('data type integer has no default operator class for access method gist'), because a plain gist index cannot do equality on room_id. Install btree_gist first.
Deleting one side of every conflicting pair is a real data decision -- which booking loses? The recipe drops the later one deterministically, but in production you may need to move or trim a range instead, not just delete.
An overlap is not the same as adjacency. tsrange '[10:00,11:00)' and '[11:00,12:00)' do not overlap (&& is false) because the upper bound is exclusive, so back-to-back bookings are allowed -- check your bound inclusivity matches your intent.
Exclusion constraints are enforced by a gist index and are not as cheap as a btree unique check on large, high-churn tables; size the index and test write throughput before relying on it for a hot booking path.
ADD CONSTRAINT takes a strong lock and scans the whole table to validate. On a large table do it in a low-traffic window; there is no NOT VALID / VALIDATE split for exclusion constraints as there is for CHECK and FK.
🔒 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 can't I add the exclusion constraint?

Because the table already contains rows that violate it -- here two bookings for the same room with overlapping times. Creating an exclusion constraint validates all existing data, and any overlapping pair makes the create fail with 23P01 'could not create exclusion constraint'. Resolve the existing overlaps first, then the create succeeds.

How do I find the conflicts before adding the constraint?

Self-join the table on the equality column and the overlap operator: SELECT ... FROM room_bookings a JOIN room_bookings b ON a.room_id = b.room_id AND a.id < b.id AND a.during && b.during. The a.id < b.id clause lists each pair once. That gives you the exact bookings to cancel, move, or trim.

Do I really need btree_gist?

Yes, when the constraint mixes equality on a scalar (room_id =) with a range overlap (during &&) in a single gist index. Plain gist has no default operator class for ordinary scalars; btree_gist adds it. If you exclude purely on ranges (during WITH && only), the built-in range gist support is enough and the extension is not required.

What's the difference between the two 23P01 messages?

'could not create exclusion constraint' is raised while building the constraint over data that already conflicts -- a schema-time error. 'conflicting key value violates exclusion constraint' is raised at run time when a new INSERT/UPDATE would overlap an existing row -- the constraint enforcing itself. Same SQLSTATE, different moment: one blocks the rule, the other is the rule working.

References

Keep going

Related & next steps