ruby 76 lines · 3 tabs

DB-Level “no overlapping ranges” with exclusion constraint

Shared by codesnips Jan 2026
3 tabs
class CreateReservations < ActiveRecord::Migration[7.1]
  def up
    enable_extension "btree_gist" unless extension_enabled?("btree_gist")

    create_table :reservations do |t|
      t.references :room, null: false, foreign_key: true
      t.datetime :starts_at, null: false
      t.datetime :ends_at, null: false
      t.tstzrange :during, null: false
      t.timestamps
    end

    execute <<~SQL
      ALTER TABLE reservations
        ADD CONSTRAINT reservations_no_overlap
        EXCLUDE USING gist (
          room_id WITH =,
          during  WITH &&
        );
    SQL
  end

  def down
    drop_table :reservations
  end
end
3 files · ruby Explain with highlit

This snippet shows how to guarantee that no two reservations for the same room can overlap in time, enforced at the database level rather than in application code. The classic naive approach reads existing rows, checks for overlap in Ruby, then inserts — a pattern that races under concurrency because two requests can both pass the check before either commits. Postgres solves this cleanly with an EXCLUDE constraint backed by a GiST index over range types.

The CreateReservations migration enables the btree_gist extension, which is required to mix a scalar equality (room_id WITH =) with a range overlap operator (during WITH &&) in the same exclusion. The during column is a tstzrange, a timestamp-with-timezone range, and the constraint reads: reject any new row whose room_id equals an existing row's and whose during overlaps it. Because the check lives in the index, concurrent inserts are serialized by Postgres and overlap is impossible regardless of interleaving. The migration is wrapped so it can be expressed in raw SQL via execute, since Rails' schema DSL doesn't model exclusion constraints directly.

In Reservation model, during is assembled from starts_at and ends_at in a before_validation hook using Range.new(starts_at, ends_at), and the [) bounds semantics matter: half-open ranges mean a booking ending at 10:00 and another starting at 10:00 do not overlap, which is the intuitive behavior for back-to-back slots. The model also adds cheap presence and ordering validations so obviously bad input fails fast without a database round trip.

The interesting part is rescue_from handling in ReservationsController. When the constraint fires, Postgres raises a PG::ExclusionViolation, which ActiveRecord wraps as ActiveRecord::StatementInvalid. Catching it in create and translating it to a 422 with a friendly message turns a low-level integrity error into a clean API response, so the database is the single source of truth while the UI still gets a sensible error.

The trade-off is that overlap failures surface as exceptions rather than pre-checked validations, so callers must handle them; the payoff is correctness under load with zero locking code. This pattern fits any resource with mutually exclusive time or numeric spans — rooms, equipment, schedules, or seat assignments.


Related snips

Share this code

Here's the card — post it anywhere.

DB-Level “no overlapping ranges” with exclusion constraint — share card
Link copied