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 succ method can be iterated. Integer, String and Symbol ranges can; Float, Rational, Time and nil ranges 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.

flowchart LR R["Range: begin, end, exclude_end?"] --> C{"What do you need?"} C -->|"is x inside?"| M["cover?, ===, include? (comparison)"] C -->|"each element"| I["each, step, % (needs succ or +)"] C -->|"two ranges"| O["overlap?, cover?(range) (comparison)"] C -->|"part of a collection"| S["array[range], string[range] (indexes)"] I --> N["Float, Time or nil begin: TypeError"]

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

1..5

Integers 1 to 5, end included.

1..5

1...5

Integers 1 to 4, end excluded.

1...5

(1..)

Endless: 1 and everything after it. Written with parentheses, see the pitfalls below.

1..

(..5)

Beginless: everything up to and including 5.

..5

(...5)

Beginless and exclusive.

...5

nil..nil

Both sides open. inspect prints the nil explicitly because (..) is not valid syntax.

nil..nil

Range.new(1, 5, true)

Same as 1...5.

1...5

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

Source: Range (the class overview, Range.new and exclude_end?) and Literals.

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

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)

first, first(n)

1, [1, 2, ...]

RangeError: cannot get the first element of beginless range

last, last(n)

RangeError

10, and last(2) is [9, 10] (Integer end)

min, max

1; max raises RangeError

min raises RangeError; max is 10 (fixed for beginless Integer ranges in Ruby 4.0)

size

Infinity

TypeError, because it cannot be iterated (since Ruby 3.4)

count

Infinity

Infinity

to_a, entries, sum

RangeError for to_a; sum never returns

TypeError

each

Runs forever (use break or a lazy enumerator)

TypeError

reverse_each

TypeError (since Ruby 3.3)

Counts down from the end when it is an Integer (since Ruby 3.3)

cover?

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

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

cover?(value)

Compares: begin <= value and value <= end (or < for an exclusive end). Never iterates. Also accepts a range: cover?(2..5) is true when the whole range fits.

Intervals of any comparable type: Float, Time, String, version numbers.

===

Calls cover? (since Ruby 2.6 for numbers and 2.7 for strings). It is what case/when, grep, Array#any?(range) and Enumerable#all?(range) call.

Conditions in when and in filters.

include?, member?

For numeric ranges behaves like cover?. For other types the two may differ: for String ranges it asks "is this value one of the elements?", producing them with succ. (Ranges of Time values, which cannot be iterated, answer like cover?, as the example shows.)

Discrete sets of elements: "a".."e" and the letter "c".

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

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"]]

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

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 (-1 is the last element). An exclusive -1 therefore 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..] and titles[..1] replace the older titles[1..-1] and titles[0..1], and RuboCop’s Style/SlicingWithRange prefers 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.

  • fetch does not accept a range; use titles[range] and handle nil, or values_at, which pads out-of-bounds positions with nil.

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

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

<=> returning a number

Creating the range at all (otherwise bad value for range), cover?, ===, include?, overlap?, min/max/minmax and clamp. Include Comparable to get the operators for free.

succ

each, to_a, map, count, include? as element search, the whole of Enumerable. The object returned by succ must also be comparable with the end.

+

step(n), since Ruby 3.4 (before that, step on non-numeric ranges used succ).

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.

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

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.

  1. A trailing .. at the end of a line continues onto the next line, so from = 1.. followed by limit = 10 assigns 1..10 to from. Always parenthesise an endless literal: from = (1..). RuboCop reports Lint/RequireRangeParentheses.

  2. .. binds more loosely than arithmetic and more tightly than ?:/assignment; method calls bind tighter than both, so 0..7 % 2 is 0..1 and 1..10.each is an error. Parenthesise: ((0..7) % 2).

  3. A range literal whose endpoints are not literals, used directly as an if or unless condition, 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 a Range object at all, and RuboCop reports Lint/FlipFlop. To test membership write cover?.

  4. A reversed range such as (5..1) is empty; it neither raises nor counts down. Use 5.downto(1), (1..5).reverse_each or a negative step on a descending range such as (5..1).step(-1).

  5. == compares endpoints with ==, so (1..3) == (1.0..3.0) is true, while eql? (used by Hash) compares them with eql? and is false. A range is a valid Hash key, but a Hash does not treat its keys as ranges: { (1..3) => :short }[2] is nil. Use case or find on the keys instead.

  6. Summing floats in a loop accumulates error (0.9999999999999999 instead of 1.0 after ten additions of 0.1), while a stepped float range computes each value from the start.

  7. 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.

  8. A large range is cheap until you call to_a, entries, sort_by or a splat on it: those allocate every element. Prefer each, lazy, sum and cover?.

  9. (1..3.5).to_a is [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

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 begin..end; there is no way to leave a side out.

Endless (1..) and beginless (..5) ranges exist and are idiomatic for open tiers, slicing and case.

2.6 and 2.7

array[1..-1] and array[0..2] are the way to slice.

array[1..] and array[..2] are shorter and RuboCop prefers them. array[(0..) % 2] slices with a step.

2.6, 2.7 and 3.0

Range#=== is include?, so it iterates and ("a".."z") === "bb" is false.

=== uses cover?, compares, and is true for "bb".

2.6 (numbers) and 2.7 (strings)

Range#step is a succ loop and returns an Enumerator.

On numbers it returns an Enumerator::ArithmeticSequence; since Ruby 3.4 every type steps with +.

2.6 and 3.4

(0.5..3).size returns a number.

size raises TypeError for ranges that cannot be iterated, which includes a Float beginning.

3.4

Detecting overlap needs custom comparison code.

Range#overlap? does it and handles exclusive, empty, endless and beginless ranges.

3.3 (infinite cases fixed in 4.0)

A range constant needs .freeze to be safe to share.

All Range objects are frozen (the endpoints themselves are not copied); dup returns an unfrozen copy.

3.0

(1..Float::INFINITY).lazy is how to write an infinite stream.

(1..).lazy is shorter, and (1..).size is Infinity.

2.6

Range#sum iterates.

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 (1..); Range#=== uses cover?; Range#%; Range#step can return an Enumerator::ArithmeticSequence.

2.7

Beginless ranges (..5); === uses cover? for String ranges too.

3.0

All Range objects are frozen; Array#[] accepts an ArithmeticSequence.

3.3

Range#overlap?; Range#reverse_each handles beginless ranges with an Integer end and raises TypeError for endless ranges.

3.4

Range#size raises TypeError for non-iterable ranges; Range#step uses + for every type.

4.0

Range#to_set checks size (no endless loop); overlap? correct for unbounded ranges; max fixed for beginless Integer ranges.

4.1

Not released as of 2026-10-18 (expected 2026-12-25). Items such as Range#clamp that appear in the development NEWS are upcoming only and are not used on this page.

References