Ranges

Published by Programming Line in Ruby. Last updated .

A Range in Ruby is an object that represents a sequence of values between a starting point and an ending point, such as “the numbers 1 through 10” or “the letters a through z.” You build one with two dots for an inclusive range, like (1..10), or three dots for a range that excludes its end, like (1...10). Ranges are everywhere in idiomatic Ruby: fixed-count loops, slicing arrays and strings, matching values in case/when, and generating sequential data without writing manual counters.

Overview / How Ranges Work

A Range is a real object, an instance of the Range class, just like an Array or a String is. You can prove this to yourself: (1..5).class returns Range. This matters because in Ruby almost everything is an object, including things other languages treat as primitives — even nil has a class (NilClass) and responds to methods. A Range object internally stores only three things: its beginning, its end, and a flag for whether the end is excluded. It does not pre-compute or store every value in between. (1..1_000_000) is created instantly and uses almost no memory, because the individual numbers only get produced on demand, when you call something like each, to_a, or map on it.

Ranges come in two flavors depending on the endpoints:

  • Numeric and alphabetic ranges (1..10, "a".."z") are enumerable: their elements implement succ (successor), so Ruby can walk from one value to the next. These support each, map, to_a, and friends.
  • Ranges with non-sequential endpoints, like a range of Floats (1.5..9.5), can still be checked with include? or cover? and used with step, but you cannot call each or to_a on them, because there is no well-defined “next float.”

Ruby also supports endless ranges (1.., open on the right) and beginless ranges (..5, open on the left). These are especially useful for slicing collections (“everything from index 2 onward”) and for readable comparisons in case/when. Since Ruby 3.0, range literals you write directly in your code are automatically frozen (immutable), the same way integer and symbol literals are — you never need to call .freeze on one yourself.

Because a Range defines the === method (checking whether a value falls inside it), ranges are the mechanism behind case/when matching against numeric buckets. Remember Ruby’s truthiness rule while working with ranges: only nil and false are falsy. An empty range’s each simply does nothing, and 0 or "" as values inside a range are still perfectly truthy on their own.

Syntax

(start..end)      # inclusive - includes 'end'
(start...end)     # exclusive - excludes 'end'
(start..)         # endless - open on the right
(..end)           # beginless - open on the left
Range.new(start, end)          # equivalent to (start..end)
Range.new(start, end, true)    # equivalent to (start...end)
Form Meaning
1..5 Inclusive range: 1, 2, 3, 4, 5
1...5 Exclusive range: 1, 2, 3, 4 (5 is left out)
1.. Endless range: 1, 2, 3, … forever
..5 Beginless range: everything up to and including 5
"a".."z" An alphabetic range built with String#succ

Examples

Example 1: Creating and inspecting ranges

numbers = (1..5)
puts numbers.to_a.inspect
puts numbers.include?(3)
puts numbers.sum

exclusive = (1...5)
puts exclusive.to_a.inspect

Output:

[1, 2, 3, 4, 5]
true
15
[1, 2, 3, 4]

numbers.to_a expands the range into a real Array. include?(3) confirms 3 is between 1 and 5. sum adds every element (Ruby computes this with a fast formula for numeric ranges rather than looping). Notice how (1...5) stops one short of 5, since three dots exclude the end.

Example 2: case/when buckets and step

def grade(score)
  case score
  when 90..100
    "A"
  when 80...90
    "B"
  when 70...80
    "C"
  else
    "F"
  end
end

puts grade(95)
puts grade(82)
puts grade(65)

(0..20).step(5) { |n| print "#{n} " }
puts

Output:

A
B
F
0 5 10 15 20 

Each when 90..100 etc. uses Range#=== under the hood to test whether score falls in that bucket, and Ruby checks the clauses top to bottom, stopping at the first match. step(5) walks the range in increments of 5, calling the block with 0, 5, 10, 15, and 20.

Example 3: slicing arrays and strings with ranges

letters = ("a".."e").to_a
puts letters.inspect

fruits = %w[apple banana cherry date fig]
puts fruits[1..3].inspect
puts fruits[1...3].inspect
puts fruits[2..].inspect
puts fruits[..2].inspect
puts fruits[-2..].inspect

Output:

["a", "b", "c", "d", "e"]
["banana", "cherry", "date"]
["banana", "cherry"]
["cherry", "date", "fig"]
["apple", "banana", "cherry"]
["date", "fig"]

String ranges use String#succ to walk from "a" to "e". Passing a range to Array#[] is the idiomatic way to slice a portion of a collection: fruits[2..] reads as “from index 2 to the end” and fruits[..2] reads as “from the start through index 2,” both far clearer than juggling manual index arithmetic. fruits[-2..] combines a negative starting index (counting from the end) with an endless range to grab the last two elements.

How It Works Step by Step

r = (1..10)
puts r.first
puts r.last
puts r.first(3).inspect
puts r.last(3).inspect
puts r.exclude_end?
puts r.class

Output:

1
10
[1, 2, 3]
[8, 9, 10]
false
Range
  1. Ruby parses (1..10) and builds a Range object holding begin = 1, end = 10, and exclude_end? = false. No elements exist yet.
  2. r.first and r.last just read the stored endpoints directly — no iteration needed.
  3. r.first(3) asks for the first three elements. Ruby starts at begin and repeatedly calls succ until it has three values, then returns them as an Array.
  4. each, map, and to_a all work the same way under the hood: start at begin, yield the current value, call succ to get the next one, and stop once the value passes (or equals, if exclusive) end.
  5. include? and cover? both check membership, but differently: cover? only compares the value against the two endpoints with <=>, which is fast and works even on ranges that cannot be enumerated (like a Float range). include? is defined by Enumerable and, for non-numeric ranges, may need to walk the sequence to find a match.

Common Mistakes

Mistake 1: Using an inclusive range where an exclusive one belongs

Array indices run from 0 to length - 1. Using 0..arr.length instead of 0...arr.length walks one index too far:

arr = ["a", "b", "c", "d", "e"]
(0..arr.length).each { |i| puts arr[i] }

Output:

a
b
c
d
e

The range includes index 5, which is out of bounds, so arr[5] evaluates to nil, and puts nil silently prints a blank line instead of raising an error. This is a classic off-by-one bug that is easy to miss because Ruby does not crash on it. Use three dots so the upper bound is excluded:

arr = ["a", "b", "c", "d", "e"]
(0...arr.length).each { |i| puts arr[i] }

Output:

a
b
c
d
e

Mistake 2: Materializing an endless range

An endless range has no upper bound, so converting it to an Array never finishes and will hang the program while consuming memory:

infinite = (1..)
all_values = infinite.to_a  # never returns - there is no end to reach
puts all_values.first

Instead, only ask for what you actually need — a bounded number of elements with first(n), or iterate with an explicit break:

infinite = (1..)
first_five = infinite.first(5)
puts first_five.inspect

infinite.each do |n|
  break if n > 5
  print "#{n} "
end
puts

Output:

[1, 2, 3, 4, 5]
1 2 3 4 5 

Mistake 3: Ordering case/when ranges wrong

case/when tests clauses top to bottom and stops at the first match, so a broad range placed before a narrow one will swallow it:

def size_label(n)
  case n
  when 0..Float::INFINITY
    "big"
  when 0..10
    "small"
  else
    "other"
  end
end

puts size_label(5)

Output:

big

5 matches 0..Float::INFINITY first, so the 0..10 branch is never reached even though 5 obviously belongs there too. Order the specific case before the general one:

def size_label(n)
  case n
  when 0..10
    "small"
  when 10..Float::INFINITY
    "big"
  else
    "other"
  end
end

puts size_label(5)
puts size_label(50)

Output:

small
big

Best Practices

  • Default to ... when the upper bound is a length or size, since valid indices stop one short of it.
  • Never call to_a, map, or sum on an endless range directly — bound it first with first(n) or a break inside each.
  • Prefer cover? over include? when you only need a fast membership check against the endpoints, especially for non-integer or very large ranges.
  • Order case/when range clauses from most specific to most general, since the first matching clause wins.
  • Use ranges for array and string slicing (arr[2..], str[..-2]) instead of manually computing start and length.
  • Reach for step when you need an arithmetic progression instead of writing a manual loop with a counter variable.
  • Remember that ranges over Float endpoints cannot use each or to_a — use step or cover? instead.

Practice Exercises

  1. Write a method even_numbers(range) that returns an Array of every even number in the given range. even_numbers(1..10) should return [2, 4, 6, 8, 10].
  2. Given a 10-element array, use range-based slicing (not a hardcoded midpoint number) to print its first half and second half on separate lines.
  3. Write a method temperature_description(celsius) that uses case/when with ranges to return "freezing", "cold", "warm", or "hot", taking care to order the clauses so every branch is actually reachable.

Summary

  • A Range is a real object storing just a start, an end, and whether the end is excluded — it does not pre-store its elements.
  • .. creates an inclusive range; ... creates one that excludes its end value.
  • Endless (1..) and beginless (..5) ranges leave one side open, which is especially handy for slicing.
  • each, map, and to_a only work on ranges whose elements support succ; use step or cover? for Float ranges.
  • cover? is a fast endpoint comparison; include? may enumerate for non-numeric ranges.
  • Ranges power case/when matching through Range#===, and the first matching clause always wins.
  • Never materialize an endless range with to_a — bound it with first(n) or a break.