ValidatesOverlap
validates_overlap provides an ActiveRecord validator for resources that must not overlap, e.g. in datetime. Think rentals, meetings, bookings, work shifts, or assignments where the same resource cannot be assigned to multiple people or entities during overlapping time periods. But it also works for other domains than datetime (see below).
You specify two attributes defining a datetime range, such as starts_at and ends_at, and the validator checks with a single SQL query whether another record overlaps that range — no records are loaded for the comparison. If one does, the record receives a normal validation error.
It also supports scoped validation (per user, room, resource, etc.), open-ended ranges (a nil start or end counts as extending forever), ranges that may touch at their boundaries (exclude_edges), required gaps between ranges or a tolerated amount of overlap (start_shift / end_shift), associations, and retrieving the conflicting records.
The range columns don't have to be dates or times: any linearly orderable column type works, such as integer ranges (ticket number blocks), decimal ranges (price bands), or string ranges (alphabetical partitions).
Note: Other Domains
Other domains / types can be checked for overlap, as long as they can be compared linearly.
e.g. The overlap check runs on plain SQL comparisons, so any linearly orderable column type works — for example integer ranges (no two records may claim overlapping number blocks), decimal ranges (price bands), or string ranges (alphabetical partitions). A nil endpoint means the range is open-ended on that side, for these types too, and the shifts work for numeric ranges as well (e.g. an integer gap or overlap tolerance). The test suite covers date, datetime, timestamp, integer, decimal, and string range columns.
⚠️ Note: Cyclic Domains can NOT be validated for overlap
Overlap validation requires a linear domain: every range must satisfy start <= end. On a cyclic (wrap-around) domain, like time, every pair of values denotes some valid range (11:00..10:00 is simply the 23-hour complement of 10:00..11:00), so a wraparound range is indistinguishable from accidentally swapped fields — no validation can tell intent from typo. This is a mathematical property of circular domains, not an implementation gap.
The validator therefore refuses :time range columns and raises OverlapValidator::UnsupportedColumnType — use datetime columns instead, or split windows that cross midnight into two records.
But cyclicity is a property of the domain, not the column type — ⚠️ user-encoded cyclic domains hide inside perfectly linear columns, where no guard can see them:
- day-of-week as integer (0..6): a Friday-to-Monday shift range
5..1wraps — same pathology as22:00..02:00, stored in an innocent:integercolumn - month numbers (1..12): a November-to-February season range
11..2 - ISO week numbers: a range from week 52 to week 2 across New Year
- angles / compass headings (0..360): a heading sector
350..10 - longitude (−180°..+180°)
- hour-of-day as integer — people re-implement
:timein an int column all the time - time-of-day (24-hour clock values without a date component)
If your domain is cyclic, the validator will silently give wrong answers for wrapping ranges. Restructure the data instead: split wrapping ranges into two linear records, or lift the values into a linear domain (e.g. datetime instead of time-of-day).
To catch inverted ranges loudly instead of silently (for any column type), pair the overlap validation with an order check on your model, e.g. validates :ends_at, comparison: { greater_than: :starts_at }.
Ruby / Rails Compatibility
Every combination below is verified on every push by the CI matrix:
| Rails | Tested with Ruby |
|---|---|
| 8.1 | 3.2, 3.3, 3.4 |
| 8.0 | 3.2, 3.3, 3.4 |
| 7.2 | 3.1, 3.2, 3.3, 3.4 |
| 7.1 | 3.0, 3.1, 3.2, 3.3 |
| 7.0 | 3.0, 3.1, 3.2 |
| 6.1 | 3.0 |
The gemspec requires activerecord >= 6.0. Rails 6.0 is not part of the test matrix, but no incompatibilities are known. The previous version 0.8.6 was compatible with Rails 3, 4, and 5.
Note for MySQL users: use DATETIME (not TIMESTAMP) columns for your range attributes — MySQL's TIMESTAMP type cannot store dates after January 2038, which matters for long-running or far-future ranges. PostgreSQL and SQLite date/time types have no such limit.
Usage
Add to your gemfile
gem 'validates_overlap'
In your model
without scope
validates :starts_at, :ends_at, :overlap => true
with scope
validates :starts_at, :ends_at, :overlap => {:scope => "user_id"}
exclude edge(s)
validates :starts_at, :ends_at, :overlap => {:exclude_edges => "starts_at"}
validates :starts_at, :ends_at, :overlap => {:exclude_edges => ["starts_at", "ends_at"]}
shift edges
The shifts move the record's own range edges before the overlap check, so you can require a gap between records — or tolerate a bounded overlap:
# widen the range: records must be at least 1 day apart (gap enforced)
validates :starts_at, :ends_at, :overlap => {:start_shift => -1.day, :end_shift => 1.day}
# shrink the range: up to 2 days of overlap are accepted
validates :starts_at, :ends_at, :overlap => {:start_shift => 2.days, :end_shift => -2.days}
non-date ranges
The overlap check runs on plain SQL comparisons, so any orderable column type works — for example integer ranges (no two records may claim overlapping number blocks), decimal ranges (price bands), or string ranges (alphabetical partitions). A nil endpoint means the range is open-ended on that side, for these types too, and the shifts work for numeric ranges as well (e.g. an integer gap or overlap tolerance). The test suite covers date, datetime, timestamp, integer, decimal, and string range columns.
class TicketBlock < ActiveRecord::Base
validates :number_start, :number_end, :overlap => true
end
TicketBlock.create!(number_start: 100, number_end: 199)
TicketBlock.new(number_start: 150, number_end: 250).valid? # => false (overlaps)
TicketBlock.new(number_start: 150, number_end: nil).valid? # => false (open-ended, overlaps)
TicketBlock.new(number_start: 200, number_end: 299).valid? # => true
define custom validation key(s) and message
validates :starts_at, :ends_at, :overlap => {:message_title => "Some validation title", :message_content => "Some validation message"}
validates :starts_at, :ends_at, :overlap => {:message_title => [:start_at, :end_at], :message_content => "Some validation message"}
with complicated relations
Example describes validation of user, positions and time slots. User can't be assigned 2 times on position which is under time slot with time overlap.
class Position < ActiveRecord::Base
belongs_to :time_slot
belongs_to :user
validates "time_slots.starts_at", "time_slots.ends_at",
:overlap => {
:query_options => {:joins => :time_slot},
:scope => { "positions.user_id" => proc{|position| position.user_id} }
}
end
apply named scopes
class ActiveMeeting < ActiveRecord::Base
validates :starts_at, :ends_at, :overlap => {:query_options => {:active => nil}}
scope :active, where(:is_active => true)
end
Overlapped records
If you need to know what records are in conflict, pass the {load_overlapped: true } as validator option and validator will set instance variable @overlapped_records to the validated object.
class ActiveMeeting < ActiveRecord::Base
validates :starts_at, :ends_at, :overlap => {:load_overlapped => true}
def overlapped_records
@overlapped_records || []
end
end
Maintainership
validates_overlap was created by Robin Bortlik, who built and maintained it starting 2011.
Since August 2026 the gem is maintained by Tilo Sloboda.
A big thank you to Robin for creating this awesome gem and for the years of work he put into it. ❤️