Ranges
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them.
A Range is an interval: an object that stores a beginning, an end and a flag that says whether the end is included. It does not store the values in between. That one fact explains most of the behaviour on this page: why (1..10**12) costs nothing to create, why (1.0..2.0) can be tested for membership but not iterated, why a range of Time values is a perfect description of a loan period, and why an endless range such as (1..) can describe an unbounded stream. The page covers how ranges are written and what endpoints are valid, how to inspect and iterate them (including step and the % operator), the three membership tests include?, cover? and ===, overlap?, ranges in case and pattern matching, slicing arrays and strings, ranges of your own classes, the interaction with Enumerable, lazy evaluation and binary search, and the pitfalls that surprise newcomers.
Related pages: the literal syntax itself is introduced in Literals and the precedence of .. and ... in Operators and Precedence. Ranges as when conditions are part of Control Expressions, and as patterns of Pattern Matching. The indexing side is covered in Arrays and Strings. Everything a finite range can do beyond its own methods comes from Enumerable, and endless ranges pair naturally with Enumerators and Lazy Evaluation. Comparison with <=> is explained in Equality, Hashing and Comparison, and Time values in Time and Dates. For how other languages model the same idea, see Go control flow (which has no range type, only for range) and Rust iterators (where std::ops::Range is itself an iterator).
Every example on this page was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the configuration of the running shelf gem (target Ruby 4.0, double-quoted strings). The output shown in # => comments is the real output. Two files switch off a handful of cops on purpose, because they demonstrate the very mistakes those cops exist to prevent; each says so in a comment at the top.
What a range is
A range has exactly three pieces of state, readable with begin, end and exclude_end?. The values in between are produced on demand, by calling succ on the beginning until the end is reached, and only when something asks for them. Three consequences follow.
-
A range is cheap: creating or passing one never allocates the elements.
-
A range is an interval first and a collection second. Membership can be decided by comparison alone (
cover?), without iterating. -
Only ranges whose beginning has a
succmethod can be iterated.Integer,StringandSymbolranges can;Float,Rational,Timeandnilranges cannot, although all of them can be compared.
Range includes Enumerable, so a finite, iterable range responds to every method described in Enumerable. Since Ruby 3.0 every Range object is frozen, and ranges are compared and hashed by value.
Creating ranges
Literals, Range.new and valid endpoints
Two operators build a range: .. includes the end value and ... excludes it. Leaving one side out gives an endless range (since Ruby 2.6) or a beginless range (since Ruby 2.7), and nil..nil is a valid range that contains everything comparable. Range.new(first, last, exclude_end = false) does the same job when the endpoints or the flag are computed.
| Form | Meaning | inspect |
|---|---|---|
|
Integers 1 to 5, end included. |
|
|
Integers 1 to 4, end excluded. |
|
|
Endless: 1 and everything after it. Written with parentheses, see the pitfalls below. |
|
|
Beginless: everything up to and including 5. |
|
|
Beginless and exclusive. |
|
|
Both sides open. |
|
|
Same as |
|
The endpoints must be comparable with each other: Range.new and the literal call begin <=> end and raise ArgumentError with the message bad value for range when the result is nil. nil is allowed as either endpoint, but Float::NAN is not, because it is not comparable with anything. The next file shows each form, inspect and to_s, the frozen state, and the two errors.
# frozen_string_literal: true
inclusive = 1..5
exclusive = 1...5
endless = (1..)
beginless = (..5)
everything = nil..nil
explicit = Range.new(1, 5, true)
p inclusive # => 1..5
p exclusive # => 1...5
p endless # => 1..
p beginless # => ..5
p everything # => nil..nil
p explicit == exclusive # => true
puts inclusive # => 1..5
puts everything.to_s.inspect # => ".."
p inclusive.frozen? # => true
p inclusive.dup.frozen? # => false
p Range.new(1, 5).frozen? # => true
p ("a".."e").to_a # => ["a", "b", "c", "d", "e"]
p ("az".."bc").to_a # => ["az", "ba", "bb", "bc"]
p (1.5..4).include?(2) # => true
begin
Range.new(1, "a")
rescue ArgumentError => e
puts "#{e.class}: #{e.message}" # => ArgumentError: bad value for range
end
begin
Range.new(1.0, Float::NAN)
rescue ArgumentError => e
puts "#{e.class}: #{e.message}" # => ArgumentError: bad value for range
end
Three details in that output deserve attention. to_s of nil..nil is "..", but inspect is nil..nil, because the shorthand is ambiguous when printed. A range literal is frozen (all Range objects are since Ruby 3.0), but a copy made with dup is not. And the two strings ("az".."bc") iterate with String#succ, so the sequence is az, ba, bb, bc: string ranges follow the same successor rules as "az".succ rather than dictionary order.
Endpoints, parentheses and precedence
The range operators have very low precedence: lower than arithmetic, comparison and the boolean operators && and ||, but higher than the ternary ?: and assignment. So 1..2 + 3 is 1..5 and 0..7 % 2 is 0..1, not an ArithmeticSequence. Wrap a literal in parentheses before calling a method on it: (1..10).each, never 1..10.each, which attempts to build a range whose end is the enumerator returned by 10.each and fails with bad value for range. RuboCop reports the risky forms as Lint/AmbiguousRange and Lint/RequireRangeParentheses. Details are in Operators and Precedence.
Looking inside a range
Boundaries, size and counting
begin and end return the endpoints exactly as given, whatever their type, and never fail. first and last are different: with no argument they also return the endpoints (so (1...10).last is 10, which is not an element of the range), but first(n) and last(n) return real elements, so the range must be iterable ((1.0..3.0).first(2) raises TypeError). min and max return elements as well: (1...10).max is 9, and an empty range such as (3..1) gives nil. A range with an excluded Float end has no largest element, so its max raises TypeError: cannot exclude non Integer end value.
# frozen_string_literal: true
def outcome
yield.inspect
rescue StandardError => e
"#{e.class}: #{e.message}"
end
closed = 1..10
open = 1...10
p [closed.begin, closed.end, open.end, open.exclude_end?] # => [1, 10, 10, true]
p [closed.first, closed.first(3), closed.last, closed.last(3)] # => [1, [1, 2, 3], 10, [8, 9, 10]]
p [open.last, open.last(3), open.max] # => [10, [7, 8, 9], 9]
p [closed.min, closed.max, closed.minmax, closed.min(2), closed.max(2)] # => [1, 10, [1, 10], [1, 2], [10, 9]]
p [closed.size, open.size, closed.count, closed.count(&:even?)] # => [10, 9, 10, 5]
p [closed.sum, (1...1).sum, (5..1).size] # => [55, 0, 0]
p (1..3.5).to_a # => [1, 2, 3]
p (1.0..3.0).first # => 1.0
p (3..1).min # => nil
endless = (1..)
beginless = (..10)
p [endless.begin, endless.end, endless.size, endless.first(2)] # => [1, nil, Infinity, [1, 2]]
p [beginless.begin, beginless.end, beginless.max] # => [nil, 10, 10]
puts(outcome { endless.to_a }) # => RangeError: cannot convert endless range to an array
puts(outcome { endless.last }) # => RangeError: cannot get the last element of endless range
puts(outcome { endless.max }) # => RangeError: cannot get the maximum of endless range
puts(outcome { beginless.first }) # => RangeError: cannot get the first element of beginless range
puts(outcome { beginless.min }) # => RangeError: cannot get the minimum of beginless range
puts(outcome { beginless.size }) # => TypeError: can't iterate from NilClass
puts(outcome { beginless.each { nil } }) # => TypeError: can't iterate from NilClass
puts(outcome { (1.0...2.0).max }) # => TypeError: cannot exclude non Integer end value
puts(outcome { (1.0..3.0).first(2) }) # => TypeError: can't iterate from Float
puts(outcome { ("a".."z").size }) # => nil
Source: Range#size, Range#count and Range#minmax.
The table below summarises how the common methods treat the two open kinds; every error is raised on purpose, because there is no sensible answer (for example no last element of an endless range).
| Method | Endless (1..) |
Beginless (..10) |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Runs forever (use |
|
|
|
Counts down from the end when it is an Integer (since Ruby 3.3) |
|
Compares only, so it works |
Compares only, so it works |
Two further size rules. Before Ruby 3.4, (0.5..3).size returned a number; since 3.4, Range#size raises TypeError for a range that cannot be iterated, which includes Float beginnings and every beginless range. For a non-numeric but iterable range such as ("a".."z"), size answers nil because computing the size would require walking it, while count walks it and answers 26. The sum of an Integer range uses an arithmetic formula, so it is instant even for enormous ranges (see the Enumerable example below).
Iterating over a range
each, reverse_each, to_a and the successor protocol
each yields the beginning, then begin.succ, and so on, stopping when the value is greater than the end (or equal to it when the end is excluded). It returns the range itself. A range whose beginning is already past the end is simply empty, with no error. Because iteration needs succ, (1.0..3.0).each raises TypeError: can't iterate from Float, and so does a range of Time values. reverse_each iterates backwards; since Ruby 3.3 it also works for beginless ranges with an Integer end, and raises TypeError for endless ones. to_a, entries, the splat [*1..3] and Array(1..3) all materialise the elements, and raise RangeError for an endless range, which is the safe behaviour: an infinite loop is replaced by an error.
step and the % operator
step(n) yields every n-th element. Without a block, on a numeric range, it returns an Enumerator::ArithmeticSequence: a lightweight object that knows its begin, end, step and exclude_end?, can report its size and last without iterating, and can be used as an index (see the slicing section). Range#% is an alias spelled as an operator, which is why 1..7) % 3) inspects as ((1..7).%(3. Mind the parentheses: 1..7 % 3 is 1..1.
Ruby 3.4 made step consistent for every type: it advances by repeatedly adding the step with +, instead of calling succ. Integer, Float, Rational, Time and your own classes that define + all work; String and Symbol ranges keep their old behaviour (an Integer step means "every n-th successor") for compatibility, and also accept a String step. Calling step with no argument is allowed only for numeric, String and Symbol ranges; for anything else it raises ArgumentError: step is required for non-numeric ranges. A step of 0 raises ArgumentError: step can't be 0, and a negative step on a forward range simply yields nothing.
# frozen_string_literal: true
def outcome
yield.inspect
rescue StandardError => e
"#{e.class}: #{e.message}"
end
total = 0
(1..3).each { |day| total += day }
p total # => 6
p((1..3).each { |day| day * 2 }) # => 1..3
p (1..7).step(3).to_a # => [1, 4, 7]
p ((1..7) % 3).to_a # => [1, 4, 7]
p (1..7).step(3) # => ((1..7).step(3))
p ((1..7) % 3).class # => Enumerator::ArithmeticSequence
p (1...7).step(3).to_a # => [1, 4]
p (10..1).step(-3).to_a # => [10, 7, 4, 1]
p (1..10).step(-1).to_a # => []
p (0.0..1.0).step(0.25).to_a # => [0.0, 0.25, 0.5, 0.75, 1.0]
p (1..).step(5).first(3) # => [1, 6, 11]
p (1..7).step(3).size # => 3
p [(1..7).step(3).begin, (1..7).step(3).end, (1..7).step(3).step, (1..7).step(3).last] # => [1, 7, 3, 7]
p (5..1).to_a # => []
p 5.downto(1).to_a # => [5, 4, 3, 2, 1]
p (1..5).reverse_each.first(2) # => [5, 4]
p (..3).reverse_each.first(2) # => [3, 2]
p ("a".."e").step(2).to_a # => ["a", "c", "e"]
p ("a".."aaa").step("a").to_a # => ["a", "aa", "aaa"]
start = Time.utc(2026, 10, 18, 9)
opening = start..(start + (3 * 3600))
p(opening.step(3600).map { |time| time.strftime("%H:%M") }) # => ["09:00", "10:00", "11:00", "12:00"]
puts(outcome { (1..10).step(0) }) # => ArgumentError: step can't be 0
puts(outcome { (1.0..3.0).each { nil } }) # => TypeError: can't iterate from Float
puts(outcome { (1..).reverse_each { nil } }) # => TypeError: can't iterate from NilClass
puts(outcome { opening.each { nil } }) # => TypeError: can't iterate from Time
puts(outcome { (1..3).step("x").to_a }) # => TypeError: String can't be coerced into Integer
Source: Range#step, Range#%, Enumerator::ArithmeticSequence and Range#reverse_each.
Floating-point steps compute each value as begin + (i * step) rather than by accumulating, so a stepped float range does not drift (the pitfalls section shows the contrast with a manual loop). The size of an ArithmeticSequence for floats uses the same rounding-aware formula, so (1.0..1.5).step(0.1).size is 6.
Membership: include?, cover? and ===
Three methods answer "is this value inside the range?", and they were not always the same.
| Method | What it does | Typical use |
|---|---|---|
|
Compares: |
Intervals of any comparable type: |
|
Calls |
Conditions in |
|
For numeric ranges behaves like |
Discrete sets of elements: |
The classic difference is visible with strings: ("a".."z").cover?("bb") is true, because "a" <= "bb" <= "z" in string comparison, but ("a".."z").include?("bb") is false, because "bb" is not one of the 26 letters produced by succ. For endless and beginless ranges of non-numeric type, include? cannot decide and raises TypeError: cannot determine inclusion in beginless/endless ranges; cover? and === still work. A value of an unrelated type (nil, "x" against an Integer range) never raises: the comparison fails and the answer is false.
# frozen_string_literal: true
def outcome
yield.inspect
rescue StandardError => e
"#{e.class}: #{e.message}"
end
# This file shows `Range#===` directly, which Style/CaseEquality discourages in application code.
# rubocop:disable Style/CaseEquality
letters = "a".."z"
p letters.include?("bb") # => false
p letters.cover?("bb") # => true
p letters === "bb" # => true
p letters.to_a.include?("bb") # => false
ratings = 1..5
p [ratings.include?(2.5), ratings.cover?(2.5), ratings === 2.5] # => [true, true, true]
p [ratings.include?(5), (1...5).include?(5), (1...5).cover?(5)] # => [true, false, false]
p [ratings.cover?(2..4), ratings.cover?(2..9), ratings.cover?(1...6), (1..).cover?(2..)] # => [true, false, true, true]
p [(..5).cover?(3), (..5).include?(3), (1..).include?(10)] # => [true, true, true]
p [ratings.cover?(nil), ratings === "x"] # => [false, false]
puts(outcome { ("a"..).include?("b") }) # => TypeError: cannot determine inclusion in beginless/endless ranges
puts(outcome { (.."c").include?("b") }) # => TypeError: cannot determine inclusion in beginless/endless ranges
# A loan period is a range of Time values: continuous, so it cannot be iterated.
lent_on = Time.utc(2026, 10, 1)
period = lent_on...(lent_on + (14 * 24 * 3600))
p period.cover?(Time.utc(2026, 10, 14, 23, 59)) # => true
p period.cover?(Time.utc(2026, 10, 15)) # => false
p period.include?(Time.utc(2026, 10, 2)) # => true
puts(outcome { period.each { nil } }) # => TypeError: can't iterate from Time
puts(outcome { period.size }) # => TypeError: can't iterate from Time
# Case equality is what `case`/`when` and Enumerable#grep call.
p [0, 3, 7, 12].grep(1..9) # => [3, 7]
p [0, 3, 7, 12].grep_v(1..9) # => [0, 12]
# rubocop:enable Style/CaseEquality
Source: Range#cover?, Range#include? and Range#===.
Rules of thumb: use cover? when you mean "between" (it is also the fast one, because it is two comparisons); use include? only when you mean "is one of these elements" for a small discrete range; and use Comparable#between? when you have two loose bounds instead of a range object. The file above turns off Style/CaseEquality because it calls === directly to show what case does; application code should write when or cover? instead.
Overlapping ranges
Range#overlap?(other) (Ruby 3.3) answers whether two ranges share at least one value, honouring inclusive and exclusive ends. The argument must be a Range (otherwise TypeError: wrong argument type Integer (expected Range)); an empty range overlaps nothing; and endless and beginless ranges are handled, including, since Ruby 4.0, the infinite cases that were wrong before. It compares with <=> and does not iterate, so it works for Time and Float ranges. Before 3.3 the idiom was a.cover?(b.begin) || b.cover?(a.begin), which gives the wrong answer for empty ranges and is easy to get wrong for exclusive and unbounded ones; replace it where you find it.
# frozen_string_literal: true
def outcome
yield.inspect
rescue StandardError => e
"#{e.class}: #{e.message}"
end
p (1..5).overlap?(5..9) # => true
p (1...5).overlap?(5..9) # => false
p (1..5).overlap?(6..9) # => false
p (1..5).overlap?(2..3) # => true
p (3..1).overlap?(1..5) # => false
p (1..).overlap?(..0) # => false
p (1..).overlap?(..1) # => true
p (nil..nil).overlap?(1..2) # => true
p (1.0..2.5).overlap?(2..4) # => true
puts(outcome { (1..5).overlap?(3) }) # => TypeError: wrong argument type Integer (expected Range)
puts(outcome { (1..5).overlap?("a".."c") }) # => false
# Two bookings of the same copy clash when their half-open periods overlap.
module Shelf
Booking = Data.define(:reader, :period) do
def clash?(other) = period.overlap?(other.period)
end
end
def day(number) = Time.utc(2026, 10, number)
ana = Shelf::Booking.new("Ana", day(1)...day(8))
ben = Shelf::Booking.new("Ben", day(8)...day(15))
eva = Shelf::Booking.new("Eva", day(7)...day(10))
p ana.clash?(ben) # => false
p ana.clash?(eva) # => true
p ben.clash?(eva) # => true
clashes = [ana, ben, eva].combination(2).select { |a, b| a.clash?(b) }
readers = clashes.map { |pair| pair.map(&:reader) }
p readers # => [["Ana", "Eva"], ["Ben", "Eva"]]
Source: Range#overlap? and the Ruby 4.0 NEWS.
Notice the half-open convention in the booking example: a period that ends on the 8th and one that starts on the 8th do not overlap, which is exactly what you want when one reader hands the book back the morning the next one collects it. Using ... for periods and for tiers avoids double-counting the boundary.
Ranges in case and pattern matching
case/when calls === on the range, so a range is the natural way to write a numeric tier. Endless and beginless ranges express the open ends without inventing sentinel values such as Float::INFINITY or 999_999: when ..0 and when 31.. are exact and self-documenting. Clauses are tried in order and the first match wins, so list them from the most specific; gaps between ranges fall through to else (or to nil when there is none), which makes contiguous half-open ranges (0.0...2.5, 2.5...4.0) the safest choice for floating-point boundaries. String ranges work too, comparing lexicographically, which is why "a" falls outside "A".."M".
Pattern matching (case/in) uses the same === for value patterns, so in 1..7, in ..0 and in 31.. are valid patterns, and they can sit inside array and hash patterns and be bound with =>. The difference is the failure mode: a case/when with no match returns nil, while a case/in with no match raises NoMatchingPatternError. That makes case/in the better choice when the tiers are meant to be exhaustive, because a gap fails loudly instead of returning nil. The same tiering style is used for the plain-Ruby policy objects in Application Architecture in Plain Ruby.
# frozen_string_literal: true
module Shelf
# Late fees in cents, decided by how many days a loan is overdue.
module LateFee
module_function
def cents(days_overdue)
case days_overdue
when ..0 then 0
when 1..7 then 25 * days_overdue
when 8..30 then 175 + (50 * (days_overdue - 7))
when 31.. then 2_500
end
end
# The same decision with pattern matching.
def tier(days_overdue)
case days_overdue
in ..0 then :on_time
in 1..7 then :grace
in 8..30 then :standard
in 31.. then :capped
end
end
def band(rating)
case rating
when 0.0...2.5 then :poor
when 2.5...4.0 then :good
when 4.0..5.0 then :excellent
else :unrated
end
end
def ordinal(code)
case code
when "A".."M" then :first_half
when "N".."Z" then :second_half
end
end
end
end
days = [-3, 0, 1, 7, 8, 30, 31, 400]
fees = days.map { |count| Shelf::LateFee.cents(count) }
tiers = days.map { |count| Shelf::LateFee.tier(count) }
p fees # => [0, 0, 25, 175, 225, 1325, 2500, 2500]
p tiers # => [:on_time, :on_time, :grace, :grace, :standard, :standard, :capped, :capped]
ratings = [0.0, 2.4999, 2.5, 3.99, 4.0, 5.0, 5.1, nil]
bands = ratings.map { |rating| Shelf::LateFee.band(rating) }
p bands # => [:poor, :poor, :good, :good, :excellent, :excellent, :unrated, :unrated]
codes = %w[A M N Z a]
halves = codes.map { |code| Shelf::LateFee.ordinal(code) }
p halves # => [:first_half, :first_half, :second_half, :second_half, nil]
reading = { title: "Dune", pages: 412, rating: 4.5 }
case reading
in { pages: ...100 } then puts "short"
in { pages: 100..399, rating: 4.0.. } then puts "medium and loved"
in { pages: 400.., rating: 4.0.. => rating }
puts "long and loved: #{rating}" # => long and loved: 4.5
in { pages: 400.. } then puts "long"
end
begin
Shelf::LateFee.tier(0.5)
rescue NoMatchingPatternError => e
puts "#{e.class}: #{e.message}" # => NoMatchingPatternError: 0.5
end
Source: Control Expressions and Pattern Matching.
The case/in block at the end shows a hash pattern whose values are ranges, including the beginless ...100 and the endless 400... When no pattern matches, the exception message carries the offending value (0.5 falls in the gap between ..0 and 1..7), which is usually enough to find the missing tier.
Ranges and slicing
A range can be the index of [], slice, slice!, []= and values_at on an Array, and of [], slice, slice! and []= on a String. A range used as an index is converted to a start and a length with these rules:
-
Negative endpoints count from the end (
-1is the last element). An exclusive-1therefore drops the last element:titles[...-1]is "all but the last". -
An endless range means "to the end", a beginless range "from the start".
titles[1..]andtitles[..1]replace the oldertitles[1..-1]andtitles[0..1], and RuboCop’sStyle/SlicingWithRangeprefers the new form. -
A start exactly at the size returns an empty array (or string), a start beyond it returns
nil. This difference lets you distinguish "the slice is empty" from "the index was out of bounds". -
If the end is before the start the result is empty:
titles[2..1]is[], not an error. -
fetchdoes not accept a range; usetitles[range]and handlenil, orvalues_at, which pads out-of-bounds positions withnil.
Since Ruby 3.0, Array#[] also accepts an Enumerator::ArithmeticSequence, so titles[(0..) % 2] takes every other element and a negative step reads backwards: (-1..0).step(-1) reverses the array. Assigning to a range slice replaces that region (string[1..2] = "UN"), and Array#fill accepts a range to overwrite part of an array.
Comparable#clamp also takes a range: 15.clamp(1..10) is 10, endless and beginless ranges clamp on one side only, and an exclusive range raises ArgumentError: cannot clamp with an exclusive range because there would be no largest valid value. Range itself has no clamp method in Ruby 4.0 (calling it raises NoMethodError); a Range#clamp is mentioned for the next release only, so do not rely on it yet. Random#rand and Kernel#rand accept a range too: an Integer range yields an Integer and a Float range a Float, with ... excluding the end.
# frozen_string_literal: true
def outcome
yield.inspect
rescue StandardError => e
"#{e.class}: #{e.message}"
end
titles = %w[Dune Emma Ulysses Beloved Hamlet]
p titles[1..2] # => ["Emma", "Ulysses"]
p titles[1...2] # => ["Emma"]
p titles[1..] # => ["Emma", "Ulysses", "Beloved", "Hamlet"]
p titles[..1] # => ["Dune", "Emma"]
p titles[...-1] # => ["Dune", "Emma", "Ulysses", "Beloved"]
p titles[-2..] # => ["Beloved", "Hamlet"]
p titles[5..] # => []
p titles[6..] # => nil
p titles[2..1] # => []
p titles[(0..) % 2] # => ["Dune", "Ulysses", "Hamlet"]
p titles[(-1..0).step(-1)] # => ["Hamlet", "Beloved", "Ulysses", "Emma", "Dune"]
p titles.values_at(0, 2..3, 8..9) # => ["Dune", "Ulysses", "Beloved", nil, nil]
p titles.first(2) # => ["Dune", "Emma"]
p titles.take(2) # => ["Dune", "Emma"]
p titles.slice(1, 2) # => ["Emma", "Ulysses"]
copy = titles.dup
p copy.slice!(3..) # => ["Beloved", "Hamlet"]
p copy # => ["Dune", "Emma", "Ulysses"]
p copy.fill("Walden", 1..2) # => ["Dune", "Walden", "Walden"]
word = "Frankenstein"
p word[0..4] # => "Frank"
p word[5..] # => "enstein"
p word[-5..-2] # => "stei"
p word[12..] # => ""
p word[13..] # => nil
shout = +"Dune"
shout[1..2] = "UN"
p shout # => "DUNe"
puts(outcome { titles.fetch(1..2) }) # => TypeError: no implicit conversion of Range into Integer
# clamp accepts a range, including endless and beginless ones.
p 15.clamp(1..10) # => 10
p 15.clamp(..10) # => 10
p(-3.clamp(0..)) # => 0
p 4.5.clamp(1..3) # => 3
puts(outcome { 15.clamp(1...10) }) # => ArgumentError: cannot clamp with an exclusive range
# Random#rand accepts ranges too: integer ranges give integers, float ranges give floats.
dice = Random.new(42)
p dice.rand(1..6).between?(1, 6) # => true
p dice.rand(0.0...1.0).class # => Float
Source: Array#[], Array#values_at, String#[], Comparable#clamp and Random.
Ranges of your own objects
Any class can be a range endpoint, and the contract is small and strict:
| Method the endpoints need | What it enables |
|---|---|
|
Creating the range at all (otherwise |
|
|
|
|
size answers nil for a non-numeric iterable range and raises TypeError for a non-iterable one. The example defines a ShelfNo value object as a Data class that is Comparable and has succ; Data#with makes succ a one-liner. See Struct, Data and Value Objects.
# frozen_string_literal: true
module Shelf
# A shelf number that can be used as a range endpoint: it needs <=> and succ.
ShelfNo = Data.define(:number) do
include Comparable
def <=>(other) = other.is_a?(ShelfNo) ? number <=> other.number : nil
def succ = with(number: number + 1)
def to_s = format("S%02d", number)
end
end
def outcome
yield.inspect
rescue StandardError => e
"#{e.class}: #{e.message}"
end
first = Shelf::ShelfNo.new(3)
last = Shelf::ShelfNo.new(6)
aisle = first..last
p aisle # => #<data Shelf::ShelfNo number=3>..#<data Shelf::ShelfNo number=6>
puts aisle.to_a.join(" ") # => S03 S04 S05 S06
p aisle.include?(Shelf::ShelfNo.new(4)) # => true
p aisle.cover?(Shelf::ShelfNo.new(9)) # => false
p aisle.size # => nil
p aisle.count # => 4
p aisle.min.to_s # => "S03"
p aisle.to_a.last.to_s # => "S06"
puts(outcome { aisle.step(2).to_a }) # => NoMethodError: undefined method '+' for an instance of Shelf::ShelfNo
puts(outcome { first..3 }) # => ArgumentError: bad value for range
puts(outcome { Object.new..Object.new }) # => ArgumentError: bad value for range
puts(outcome { (first..).each { |shelf| break shelf.to_s if shelf.number > 4 } }) # => "S05"
Source: Range and Comparable.
Because step needs + in Ruby 3.4 and later, the call aisle.step(2).to_a fails with NoMethodError until the class defines +; this is a behaviour change from older Ruby, where step on such a range called succ repeatedly and worked.
Ranges with Enumerable, laziness and binary search
A finite iterable range behaves like a collection that is never built. each_slice and each_cons window it, sum of an Integer range is a closed formula (so (1..10**12).sum is instant, while a block form such as sum { ... } must iterate), and zip, partition, find, filter_map and the rest of Enumerable work as on an array. to_set builds a Set, which needs no require; since Ruby 4.0 it raises RangeError for an endless range instead of looping.
Endless ranges need a lazy chain, a first(n), take or break, because eager methods would never finish. (1..).lazy processes one element at a time, so (1..).lazy.map { ... }.select { ... }.first(3) terminates. The older spelling (1..Float::INFINITY) works too, but it mixes an Integer beginning with a Float end and says less clearly that there is no end; prefer (1..). More about this in Enumerators and Lazy Evaluation.
Range#bsearch performs binary search on a range of Integers or Floats without building it. In find-minimum mode the block returns true or false, with the answers being false for every value below the answer and true from it onwards, and the result is the first value for which the block is true. In find-any mode it returns a number: 0 for the target, negative to search below, positive above. An endless range works for Integers because the search first doubles an upper bound. A non-numeric range raises TypeError.
# frozen_string_literal: true
def outcome
yield.inspect
rescue StandardError => e
"#{e.class}: #{e.message}"
end
# Pages of results: windowing a range without building an array first.
catalogue_size = 23
page_size = 10
slices = (1..catalogue_size).each_slice(page_size)
pages = slices.map { |slice| [slice.first, slice.last] }
p pages # => [[1, 10], [11, 20], [21, 23]]
p slices.size # => 3
p (1..5).each_cons(2).to_a # => [[1, 2], [2, 3], [3, 4], [4, 5]]
# Sums have a closed form for integer ranges, so they cost nothing even for huge ones.
big = 1..(10**12)
squares = (1..10).sum { |n| n * n }
p (1..100).sum # => 5050
p big.sum # => 500000000000500000000000
p squares # => 385
p ("a".."e").sum("") # => "abcde"
# Everything from Enumerable is available on finite ranges.
evens, odds = (1..6).partition(&:even?)
p [evens, odds] # => [[2, 4, 6], [1, 3, 5]]
first_big_square = (1..10).find { |n| n * n > 40 }
doubled_odds = (1..10).filter_map { |n| n * 2 if n.odd? }
p [first_big_square, doubled_odds] # => [7, [2, 6, 10, 14, 18]]
p (1..3).zip(4..6) # => [[1, 4], [2, 5], [3, 6]]
p (1..3).to_set # => Set[1, 2, 3]
# Endless ranges plus a lazy enumerator give streams.
cubes = (1..).lazy.map { |n| n**3 }.select(&:even?)
p cubes.first(3) # => [8, 64, 216]
puts(outcome { (1..).to_set }) # => RangeError: cannot convert endless range to a set
puts(outcome { (1..).each_slice(2).first(2) }) # => [[1, 2], [3, 4]]
# Binary search needs a sorted range and a block that is false and then true (find-minimum mode)
# or returns a number (find-any mode).
p((0..100).bsearch { |n| n * n >= 50 }) # => 8
p((1..).bsearch { |n| n * n >= 10_000 }) # => 100
p((0.0..10.0).bsearch { |x| x >= 3.3 }) # => 3.3
p((0..100).bsearch { |n| 42 <=> n }) # => 42
puts(outcome { ("a".."z").bsearch { true } }) # => TypeError: can't do binary search for String
Source: Range#bsearch, Enumerable#sum, Enumerable and Enumerator::Lazy.
Pitfalls
The numbered cases in the next file are the ones that cost the most debugging time. Each one produces valid code that runs silently, which is why the Ruby parser cannot save you.
-
A trailing
..at the end of a line continues onto the next line, sofrom = 1..followed bylimit = 10assigns1..10tofrom. Always parenthesise an endless literal:from = (1..). RuboCop reportsLint/RequireRangeParentheses. -
..binds more loosely than arithmetic and more tightly than?:/assignment; method calls bind tighter than both, so0..7 % 2is0..1and1..10.eachis an error. Parenthesise:((0..7) % 2). -
A range literal whose endpoints are not literals, used directly as an
iforunlesscondition, is a flip-flop: a stateful condition that is true from the first expression until the second. It is rarely what you mean, it is not aRangeobject at all, and RuboCop reportsLint/FlipFlop. To test membership writecover?. -
A reversed range such as
(5..1)is empty; it neither raises nor counts down. Use5.downto(1),(1..5).reverse_eachor a negative step on a descending range such as(5..1).step(-1). -
==compares endpoints with==, so(1..3) == (1.0..3.0)is true, whileeql?(used byHash) compares them witheql?and is false. A range is a valid Hash key, but a Hash does not treat its keys as ranges:{ (1..3) => :short }[2]isnil. Usecaseorfindon the keys instead. -
Summing floats in a loop accumulates error (
0.9999999999999999instead of1.0after ten additions of0.1), while a stepped float range computes each value from the start. -
A range stores the endpoint objects you passed in, without copying them: after
s = +"a"; r = (s.."z"); s << "b"the range is"ab".."z". Use frozen strings (# frozen_string_literal: true) as endpoints. -
A large range is cheap until you call
to_a,entries,sort_byor a splat on it: those allocate every element. Prefereach,lazy,sumandcover?. -
(1..3.5).to_ais[1, 2, 3]: for an Integer beginning the Float end only caps the iteration. A Float beginning cannot be iterated at all.
# frozen_string_literal: true
# These lines are deliberate mistakes, so the cops that would catch them are switched off.
# rubocop:disable Lint/RequireRangeParentheses, Lint/AmbiguousRange, Lint/FlipFlop
# 1. A trailing endless range swallows the next line.
from = 1..
limit = 10
p from # => 1..10
p limit # => 10
# 2. Operators bind tighter than `..`, and method calls bind tighter still.
p 1..2 + 3 # => 1..5
p 0..7 % 2 # => 0..1
p((0..7) % 2) # => ((0..7).%(2))
p((0..7).step(2).to_a) # => [0, 2, 4, 6]
# 3. A range written with variables inside a condition is a flip-flop, not a Range.
lines = %w[intro START body body END outro]
inside = lines.select { |line| true if (line == "START")..(line == "END") }
p inside # => ["START", "body", "body", "END"]
# 4. Reversed ranges are empty, they do not count down.
p (5..1).to_a # => []
p (5..1).sum # => 0
p 5.downto(1).to_a # => [5, 4, 3, 2, 1]
p (1..5).reverse_each.to_a # => [5, 4, 3, 2, 1]
# 5. Equal values are not always equal ranges, and ranges never act as Hash lookup patterns.
p (1..3) == (1.0..3.0) # => true
p (1..3).eql?(1.0..3.0) # => false
p({ (1..3) => :short }[2]) # => nil
p({ (1..3) => :short }[1..3]) # => :short
# 6. Repeated float addition drifts, a stepped range does not.
drift = []
tick = 0.0
while tick <= 1.0
drift << tick
tick += 0.1
end
p drift.last # => 0.9999999999999999
p (0.0..1.0).step(0.1).to_a.last # => 1.0
# rubocop:enable Lint/RequireRangeParentheses, Lint/AmbiguousRange, Lint/FlipFlop
Source: Operator precedence, Control Expressions and Range. See also Common Mistakes and Gotchas.
Where older books and tutorials are outdated
Ruby books and tutorials written for Ruby 2.x or early 3.x describe the range API as it was; several of the points below changed behaviour, not just spelling.
| Older text says | Ruby 4.0 reality | Since |
|---|---|---|
A range is |
Endless |
2.6 and 2.7 |
|
|
2.6, 2.7 and 3.0 |
|
|
2.6 (numbers) and 2.7 (strings) |
|
On numbers it returns an |
2.6 and 3.4 |
|
|
3.4 |
Detecting overlap needs custom comparison code. |
|
3.3 (infinite cases fixed in 4.0) |
A range constant needs |
All |
3.0 |
|
|
2.6 |
|
For Integer ranges without a block it uses a formula, so it is instant; with a block it still iterates. |
2.4 |
Version notes
| Ruby | Range changes |
|---|---|
2.6 |
Endless ranges |
2.7 |
Beginless ranges |
3.0 |
All |
3.3 |
|
3.4 |
|
4.0 |
|
4.1 |
Not released as of 2026-10-18 (expected 2026-12-25). Items such as |
References
-
Enumerator::ArithmeticSequence and Comparable (
clamp,between?) -
Literals, Precedence, Control Expressions and Pattern Matching
-
Ruby 3.0.0 NEWS, 3.3.0, 3.4.0 and 4.0.0
-
RuboCop Lint cops (
Lint/AmbiguousRange,Lint/RequireRangeParentheses,Lint/FlipFlop) and Style cops (Style/SlicingWithRange,Style/CaseEquality)