Range Objects in Ruby — More Than Just Iteration
Most Ruby developers use ranges for iteration and not much else. (1..10).each, maybe ('a'..'z') for alphabet work, perhaps a case condition or two. But ranges in Ruby are full objects with a rich interface — membership testing, coverage checks, step-based iteration, pattern matching, endless and beginless forms — and understanding them properly opens up more expressive solutions to problems you might currently solve with verbose conditionals or manual loops.
Range Basics — Inclusive vs Exclusive
Ruby has two range operators: .. (inclusive, includes end value) and ... (exclusive, excludes end value). The distinction matters more than it seems.
Example:
inclusive = (1..10)
exclusive = (1...10)
inclusive.include?(10) # => true
exclusive.include?(10) # => false
inclusive.to_a # => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
exclusive.to_a # => [1, 2, 3, 4, 5, 6, 7, 8, 9]
# Exclusive ranges are ideal for array indices and date ranges
# where the end boundary shouldn't be included
(0...array.length).each { |i| process(array[i]) }
# Date: "from January 1st up to but not including February 1st"
jan = (Date.new(2024, 1, 1)...Date.new(2024, 2, 1))
jan.include?(Date.new(2024, 1, 31)) # => true
jan.include?(Date.new(2024, 2, 1)) # => false
Membership Testing — include? vs cover?
Two methods test membership in a range, and they behave differently with non-discrete values.
Example:
float_range = (1.0..2.0)
# cover? checks bounds only (< start, > end comparison)
float_range.cover?(1.5) # => true — fast, O(1)
float_range.cover?(1.999) # => true
# include? iterates the range (only fast for integers and explicitly iterable ranges)
float_range.include?(1.5) # => true (but uses cover? internally for float ranges)
# The difference matters for discrete, non-numeric ranges
alpha = ('a'..'z')
alpha.cover?('m') # => true (string comparison: 'a' <= 'm' <= 'z')
alpha.include?('m') # => true (iterates through alphabet)
# cover? is consistently O(1); include? can be O(n) for large ranges
large = (1..10_000_000)
large.cover?(9_999_999) # instantly
large.include?(9_999_999) # also fast for integers, but use cover? for clarity
For large ranges or ranges of objects that don’t respond to succ, prefer cover?. It’s the intent you want: “is this value within the range bounds?”
Step-Based Iteration
Ranges aren’t just each. The step method lets you iterate at intervals without building an intermediate array.
Example:
# Integer steps
(1..20).step(3).to_a # => [1, 4, 7, 10, 13, 16, 19]
# Float steps
(0.0..1.0).step(0.25).to_a # => [0.0, 0.25, 0.5, 0.75, 1.0]
# Practical: generate time slots every 30 minutes
start_time = Time.parse("09:00")
end_time = Time.parse("17:00")
slots = (0...(end_time - start_time).to_i).step(1800).map do |offset|
start_time + offset
end
# => [09:00, 09:30, 10:00, ... 16:30]
Example:
# step returns an Enumerator — chain lazily for large ranges
(1..Float::INFINITY).step(2).lazy.first(5)
# => [1, 3, 5, 7, 9] — odd numbers, no infinite loop
# Ruby 3.x: Numeric#step as Enumerator
stepper = 0.step(by: 0.5)
stepper.next # => 0
stepper.next # => 0.5
stepper.next # => 1.0
Endless and Beginless Ranges (Ruby 2.6+)
Ruby 2.6 added endless ranges (n..), and Ruby 2.7 added beginless ranges (..n). Both have practical uses in slice operations, comparisons, and case expressions.
Example:
# Endless range — from n to infinity
(5..).include?(1_000_000) # => true
(5..).include?(4) # => false
# Array slicing with endless/beginless ranges
arr = [10, 20, 30, 40, 50]
arr[2..] # => [30, 40, 50] (from index 2 to end)
arr[..2] # => [10, 20, 30] (from start to index 2)
arr[1...-1] # => [20, 30, 40] (excluding first and last)
Example:
# Case expressions with beginless/endless ranges — very readable
def risk_level(score)
case score
when ..30 then :high_risk
when 31..60 then :medium_risk
when 61.. then :low_risk
end
end
risk_level(25) # => :high_risk
risk_level(55) # => :medium_risk
risk_level(75) # => :low_risk
# Comparable to ActiveRecord query ranges
User.where(age: 18..) # WHERE age >= 18
User.where(score: ..50) # WHERE score <= 50
User.where(created_at: 1.week.ago..) # WHERE created_at >= ...
Ranges in Pattern Matching (Ruby 3.x)
Ranges integrate with Ruby 3’s pattern matching syntax, enabling expressive value extraction and classification.
Example:
# Pattern matching with ranges
case response_time_ms
in (..100)
log.info "fast"
in (101..500)
log.warn "acceptable"
in (501..)
log.error "slow: #{response_time_ms}ms"
end
# Deconstruct with ranges in find patterns
logs = [
{ level: :info, duration: 45 },
{ level: :warn, duration: 320 },
{ level: :error, duration: 750 }
]
slow_logs = logs.select { |log| log in { duration: (500..) } }
# => [{ level: :error, duration: 750 }]
Custom Range Objects
Any class can participate in ranges by implementing <=> (the spaceship operator) and optionally succ (to make the range iterable).
Example:
class SemVer
include Comparable
attr_reader :major, :minor, :patch
def initialize(version_string)
@major, @minor, @patch = version_string.split('.').map(&:to_i)
end
def <=>(other)
[major, minor, patch] <=> [other.major, other.minor, other.patch]
end
def to_s = "#{major}.#{minor}.#{patch}"
end
v1 = SemVer.new("1.2.0")
v2 = SemVer.new("2.0.0")
v3 = SemVer.new("1.5.3")
(v1..v2).cover?(v3) # => true
(v1..v2).cover?(SemVer.new("0.9.9")) # => false
Adding succ to SemVer would make the range iterable with each, but for most custom range use cases, cover? and include? are all you need.
Pro-Tip: Use ranges in
ActiveRecordwhereclauses instead of chained comparisons — they generate cleaner SQL and read better.User.where(age: 18..65)generatesWHERE age BETWEEN 18 AND 65, whileUser.where(age: 18..)generatesWHERE age >= 18. Both are more readable thanUser.where("age >= ? AND age <= ?", 18, 65), and they compose better with other scopes since they’re not raw SQL strings.
Conclusion
Ranges in Ruby are first-class objects that go well beyond each. Membership testing with cover?, step-based iteration, endless and beginless forms, pattern matching integration, and custom comparable objects — each of these is a tool that simplifies code that would otherwise require manual comparisons and explicit iteration. The developers who internalize ranges as a general-purpose concept rather than just an iteration shortcut write code that’s more expressive and easier to read.
FAQs
Q1: What’s the performance difference between cover? and include??
For integer ranges, both are fast. For float ranges or large ranges, cover? is O(1) because it only compares against the range endpoints. include? can be O(n) for non-discrete ranges because it may need to iterate. Prefer cover? when you only need to check if a value falls within the bounds.
Q2: Can I use ranges with strings?
Yes. String ranges work using lexicographic ordering. ('a'..'z').include?('m') is true. ('a'..'z').to_a gives you the alphabet. Be aware that ('a'..'z') iterates via String#succ, which increments the string: 'a'.succ is 'b', 'z'.succ is 'aa'.
Q3: Are endless ranges supported in Rails where clauses?
Yes, from Rails 5.2+. User.where(age: 18..) generates WHERE age >= 18. Beginless ranges generate WHERE age <= n. Both work with ActiveRecord query building.
Q4: Can I create a range of custom objects?
Yes, if they implement <=>. Add succ to make the range iterable. Without succ, ranges of custom objects support cover? and include? but raise TypeError if you call each or to_a.
Q5: How do I check if two ranges overlap?
Ruby doesn’t have a built-in overlap check, but (a..b).cover?(c) || (c..d).cover?(a) covers most cases. For ActiveRecord, you can use range queries with overlaps_with if you’re storing ranges in PostgreSQL range columns.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀