Ranges
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 implementsucc(successor), so Ruby can walk from one value to the next. These supporteach,map,to_a, and friends. - Ranges with non-sequential endpoints, like a range of
Floats (1.5..9.5), can still be checked withinclude?orcover?and used withstep, but you cannot calleachorto_aon 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
- Ruby parses
(1..10)and builds aRangeobject holdingbegin = 1,end = 10, andexclude_end? = false. No elements exist yet. r.firstandr.lastjust read the stored endpoints directly — no iteration needed.r.first(3)asks for the first three elements. Ruby starts atbeginand repeatedly callssuccuntil it has three values, then returns them as an Array.each,map, andto_aall work the same way under the hood: start atbegin, yield the current value, callsuccto get the next one, and stop once the value passes (or equals, if exclusive)end.include?andcover?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 byEnumerableand, 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 alengthorsize, since valid indices stop one short of it. - Never call
to_a,map, orsumon an endless range directly — bound it first withfirst(n)or abreakinsideeach. - Prefer
cover?overinclude?when you only need a fast membership check against the endpoints, especially for non-integer or very large ranges. - Order
case/whenrange 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
stepwhen you need an arithmetic progression instead of writing a manual loop with a counter variable. - Remember that ranges over
Floatendpoints cannot useeachorto_a— usesteporcover?instead.
Practice Exercises
- 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]. - 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.
- Write a method
temperature_description(celsius)that usescase/whenwith ranges to return"freezing","cold","warm", or"hot", taking care to order the clauses so every branch is actually reachable.
Summary
- A
Rangeis 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, andto_aonly work on ranges whose elements supportsucc; usesteporcover?for Float ranges.cover?is a fast endpoint comparison;include?may enumerate for non-numeric ranges.- Ranges power
case/whenmatching throughRange#===, and the first matching clause always wins. - Never materialize an endless range with
to_a— bound it withfirst(n)or abreak.
