The Constraint That Kills Booking Race Conditions

Double-bookings happen when SELECT-then-INSERT races itself. PostgreSQL's EXCLUDE constraint prevents overlapping ranges at the DB level — no locks needed.

Share

You've written this code a dozen times. Query for existing bookings that overlap the requested time slot. If none come back, insert the new reservation. Ship it.

Then two requests hit simultaneously. Both queries return empty. Both inserts succeed. You just double-booked the conference room.

Why this matters

The SELECT-then-INSERT pattern is a classic race condition. Developers try to fix it with SERIALIZABLE isolation (which tanks throughput), advisory locks (which scatter locking logic across your codebase), or SELECT ... FOR UPDATE (which doesn't help when no matching row exists yet).

PostgreSQL has a better answer built in. The EXCLUDE constraint is a generalized form of UNIQUE. Instead of preventing duplicate point values, it prevents rows that satisfy a custom operator — like range overlap. It's enforced atomically at write time. No race is possible.

How it works

A UNIQUE constraint says "no two rows share the same value." An EXCLUDE constraint says "no two rows satisfy this operator for these columns."

For scheduling, you store a time range using PostgreSQL's tstzrange type. Then you create an exclusion constraint that rejects any new row whose resource_id matches an existing row and whose time range overlaps it. The overlap operator is &&.

Because this uses a GiST index internally, you need the btree_gist extension to support equality checks on scalar types like UUID or text alongside the range type.

Where this helps

  • Resource scheduling — meeting rooms, equipment, parking spots
  • Subscriptions and billing — prevent overlapping billing windows for the same plan tier
  • Shift management — no employee gets assigned two overlapping shifts
  • Apartment or hotel bookings — date-range conflicts blocked at the source

Watch out

GiST indexes are slower than B-tree for equality lookups, so don't use EXCLUDE as a replacement for standard UNIQUE constraints. The constraint violation error is generic — you'll need to catch it in your application and map it to a user-friendly message. This is PostgreSQL-specific; MySQL and SQLite don't support it. And if you're running PostgreSQL 12 or older, EXCLUDE constraints don't work on partitioned tables.

Try it yourself

CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE bookings (
    id          UUID DEFAULT gen_random_uuid() PRIMARY KEY,
    resource_id UUID NOT NULL,
    time_range  tstzrange NOT NULL,
    EXCLUDE USING gist (
        resource_id WITH =,
        time_range  WITH &&
    )
);

-- First booking: succeeds
INSERT INTO bookings (resource_id, time_range)
VALUES (
    '6f3c1a20-6e1f-4b3a-9c2d-000000000001',
    tstzrange('2026-07-08 09:00+00', '2026-07-08 10:00+00')
);

-- Overlapping booking: FAILS with constraint violation
INSERT INTO bookings (resource_id, time_range)
VALUES (
    '6f3c1a20-6e1f-4b3a-9c2d-000000000001',
    tstzrange('2026-07-08 09:30+00', '2026-07-08 10:30+00')
);

TL;DR

  • PostgreSQL's EXCLUDE constraint blocks overlapping ranges atomically at write time
  • It eliminates race conditions in booking logic without application-level locks or serializable isolation
  • Add btree_gist, store a tstzrange, and let the database enforce scheduling integrity