Preventing Overlapping Bookings with PostgreSQL Exclusion Constraints

Stop race conditions and double bookings permanently by leveraging PostgreSQL range types and GiST exclusion constraints at the database level.

Preventing Overlapping Bookings with PostgreSQL Exclusion Constraints
In this essay

Application-level validation for time slots inevitably produces double bookings under concurrent traffic. PostgreSQL eliminates this entire class of race conditions through native range types and GiST exclusion constraints, enforcing non-overlapping invariants directly in the storage engine.

The Flaw in Application-Level Checks and Distributed Locks

When developers build scheduling systems—whether for hotel rooms, meeting spaces, or doctor appointments—the default approach usually involves two sequential database queries:

  1. Query the database to check if an overlapping slot exists: SELECT COUNT(*) FROM reservations WHERE room_id = $1 AND start_time < $new_end AND end_time > $new_start.
  2. If the count is zero, proceed with INSERT INTO reservations ....

Under concurrent traffic, this pattern suffers from a classic Time-of-Check to Time-of-Use (TOCTOU) race condition. If two users submit overlapping bookings within milliseconds of each other, both requests execute Step 1 before either executes Step 2. Both read an empty conflict list, both conclude the room is available, and both commit their insert. Your database now holds corrupted, overlapping reservations.

Teams frequently attempt to plug this hole by introducing distributed locks with Redis or optimistic locking tokens. However, distributed locks introduce operational fragility. If a process experiences a garbage collection pause or network blip while holding a lock, the TTL expires, allowing another worker to acquire the lock and cause identical collision bugs. Furthermore, distributed locks add latency and require maintaining external infrastructure. Pessimistic row locking with SELECT ... FOR UPDATE cannot help here either, because you cannot lock a row that does not yet exist in the table.

Modeling Time as First-Class Ranges

Diagram illustrating concurrent booking race conditions versus database exclusion constraints

PostgreSQL solves this structurally by treating time intervals as first-class data types (tsrange, tstzrange, daterange). Instead of storing separate start_time and end_time columns and manually juggling boundary conditions across SQL queries, you store a single continuous range column.

PostgreSQL defaults to half-open intervals [start, end), meaning the range includes the lower bound but excludes the upper bound. This behavior matches real-world scheduling logic: a guest checking out at 12:00 PM does not conflict with a new guest checking in at 12:00 PM.

-- A half-open timestamp range from 14:00 to 18:00
SELECT tstzrange(
  '2026-10-02 14:00:00+00',
  '2026-10-02 18:00:00+00',
  '[)'
);

PostgreSQL provides native overlap operators for range types. The && operator returns TRUE if two ranges intersect by even a single microsecond:

-- Returns true because the intervals overlap
SELECT tstzrange('2026-10-02 10:00:00Z', '2026-10-02 12:00:00Z') 
    && tstzrange('2026-10-02 11:30:00Z', '2026-10-02 13:00:00Z');

Enforcing Non-Overlap with GiST Exclusion Constraints

Standard UNIQUE constraints in relational databases only enforce scalar equality (=). They cannot evaluate whether two date intervals overlap. PostgreSQL exclusion constraints generalize the concept of uniqueness to arbitrary index-backed operators.

To enforce that no two active bookings for the same room can overlap in time, we combine the standard scalar equality operator (=) on room_id with the range overlap operator (&&) on the reservation interval.

Because standard B-tree indexes only support scalar comparisons, you must first enable the btree_gist extension, which provides GiST operator classes for scalar types like UUID, text, and integer.

CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE reservations (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    room_id UUID NOT NULL,
    booking_period TSTZRANGE NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'confirmed',
    
    CONSTRAINT no_overlapping_room_bookings 
    EXCLUDE USING gist (
        room_id WITH =,
        booking_period WITH &&
    )
    WHERE (status != 'cancelled')
);

Notice the partial index predicate WHERE (status != 'cancelled'). When a customer cancels a booking, updating the status immediately releases the time window for other users without requiring row deletion or constraint bypass hacks.

Intercepting Conflicts in Application Logic

When two concurrent transactions attempt to insert overlapping periods for the same room_id, the database engine intercepts the conflict at the index level.

If Transaction A inserts a row, Transaction B's concurrent insert is placed into a waiting state until Transaction A commits. As soon as Transaction A commits, Transaction B is immediately rejected by the storage engine with a standard error code 23P01 (exclusion_violation):

ERROR: conflicting key value violates exclusion constraint "no_overlapping_room_bookings"
DETAIL: Key (room_id, booking_period)=(d3b07384..., ["2026-10-02 14:00:00+00","2026-10-02 16:00:00+00")) conflicts with existing key (room_id, booking_period)=(d3b07384..., ["2026-10-02 13:00:00+00","2026-10-02 15:00:00+00")).

In your web framework, catch database integrity errors with SQLSTATE 23P01 and map them cleanly to an HTTP 409 Conflict response. Your application layer no longer requires distributed locks, complex mutexes, or fragile manual validations.

Production Trade-offs

While exclusion constraints offer an airtight solution for concurrency, keep these operational considerations in mind:

  • Write Overhead: GiST index maintenance requires more CPU and memory per write compared to standard B-trees. For read-heavy reservation systems, this cost is negligible, but it is worth monitoring during massive batch ingestions.
  • Storage Growth: GiST indexes can experience bloat over long periods of heavy updates and deletes. Ensure your autovacuum settings are tuned appropriately for active scheduling tables.
  • Bulk Operations: Bulk loading thousands of pre-validated records is faster if you create the exclusion constraint after loading the initial dataset.

Offloading temporal invariants to PostgreSQL removes an entire category of production bugs, providing bulletproof consistency with zero race conditions.

Sources

  1. postgresql.org
  2. postgresql.org

AI-assisted · Reviewed by PKN

0

Responses

Loading comments…