Lazy Enumerators
A lazy enumerator is a version of an Enumerable chain that doesn’t compute anything until you actually ask for a result. Instead of building a brand-new array after every map or select in a chain, a lazy chain processes one element at a time, all the way through the pipeline, only when something forces it to produce output. This makes it possible to work with infinite sequences (like all natural numbers) and to avoid allocating throwaway intermediate arrays when processing large collections.
Overview / How It Works
Normally, Ruby’s Enumerable methods are eager: each one runs to completion and returns a brand-new array before the next method in the chain even starts. If you write (1..1_000_000).map { |n| n * 2 }.select { |n| n > 100 }.first(3), Ruby first builds a full 1,000,000-element array from map, then scans the entire thing again in select to build a second array, and only then grabs the first three elements — even though you only ever needed three. Worse, if the range were infinite, map would never finish, and you’d never even reach select.
Calling .lazy on any enumerable object returns an instance of Enumerator::Lazy, a special wrapper around the original collection. Every familiar method you chain onto it — map, select, reject, flat_map, take_while, drop_while, filter_map, and more — does not run immediately. Instead, each call returns another Enumerator::Lazy that simply remembers what transformation to apply. Nothing actually executes until you call a forcing method: first(n), to_a, force, each, reduce, or similar. At that point, Ruby pulls one element from the original source, runs it through every step of the chain in order, and only fetches the next source element once it needs more output. This is called pull-based (or demand-driven) evaluation, as opposed to the eager, push-based style where each stage processes the whole collection before handing off to the next.
This is also why lazy enumerators are the standard way to work with infinite sequences in Ruby, such as (1..Float::INFINITY) or the endless range (1..). An eager map on an infinite range would simply never return. A lazy map returns instantly because it hasn’t done any work yet — it’s just a description of what to do when asked.
Syntax
enumerable.lazy
.map { |item| transform(item) }
.select { |item| condition?(item) }
.first(n)
| Part | Meaning |
|---|---|
enumerable.lazy |
Wraps any Enumerable (an Array, Range, Hash, custom object with each, etc.) in an Enumerator::Lazy. |
.map, .select, .reject, .flat_map, .take, .take_while, .drop, .drop_while, .filter_map, .grep, .zip |
Lazy versions of the usual Enumerable methods. Each returns another Enumerator::Lazy instead of an Array. |
.first(n) / .first |
A forcing method. Pulls just enough elements through the whole chain to produce n results (or one, without the argument), then returns a real Array or single value. |
.to_a / .force |
Forcing methods that run the chain to completion and return a real Array. Only safe on finite sequences. |
.each { |x| ... } |
A forcing method that runs the chain for every element and yields each result to the block. |
Examples
Example 1: Filtering an infinite range
result = (1..Float::INFINITY).lazy.select { |n| n % 3 == 0 }.first(5)
puts result.inspect
Output:
[3, 6, 9, 12, 15]
The range (1..Float::INFINITY) has no upper bound, so an eager select would run forever. Because we called .lazy first, select just builds a pipeline description. Calling .first(5) pulls numbers from the range one at a time — 1, 2, 3, 4, 5, 6, … — testing each against the block, and stops the instant it has collected five multiples of 3.
Example 2: Eager vs. lazy evaluation order
puts "Eager (map runs on all elements before select):"
eager = (1..5).map { |n| puts "map #{n}"; n * 2 }.select { |n| puts "select #{n}"; n > 5 }
puts eager.inspect
puts "\nLazy (map and select run one element at a time):"
lazy = (1..5).lazy.map { |n| puts "map #{n}"; n * 2 }.select { |n| puts "select #{n}"; n > 5 }.first(2)
puts lazy.inspect
Output:
Eager (map runs on all elements before select):
map 1
map 2
map 3
map 4
map 5
select 2
select 4
select 6
select 8
select 10
[6, 8, 10]
Lazy (map and select run one element at a time):
map 1
select 2
map 2
select 4
map 3
select 6
map 4
select 8
[6, 8]
In the eager version, map runs to completion over all five numbers (printing all five map lines) before select even starts. In the lazy version, notice the interleaving: Ruby pulls 1, runs it through map then immediately through select, decides whether to keep it, and only then pulls the next source value. It stops entirely after collecting two matches (6 and 8) at n = 4, never touching 5 at all.
Example 3: Lazily finding the first 10 prime numbers
def prime?(n)
return false if n < 2
(2..Math.sqrt(n)).none? { |i| n % i == 0 }
end
primes = (2..Float::INFINITY).lazy.select { |n| prime?(n) }.first(10)
puts primes.inspect
Output:
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
This is a realistic use case: you don’t know in advance how far into the number line you’ll need to search to find 10 primes, so an infinite lazy range is the natural fit. The select block is only ever evaluated on the numbers actually needed to find the answer, never on the whole (infinite!) set of integers.
How It Works Step by Step
Walking through Example 3 in detail: calling .lazy on (2..Float::INFINITY) wraps the range in an Enumerator::Lazy without touching any numbers yet. Calling .select { |n| prime?(n) } on that returns a new Enumerator::Lazy that simply remembers "when asked for values, pull from the range and keep only the ones where prime?(n) is true." No numbers have been generated and no block has run.
The real work starts when .first(10) is called. Internally, Ruby asks the chain for one value. The chain asks the range for its first element (2), runs prime?(2), gets true, and yields 2 as the first result. .first(10) now has one of the ten values it needs, so it asks for another. The chain pulls 3 from the range, tests it, yields it. This repeats — pulling 4 (fails the test, discarded, chain immediately asks for the next value instead of yielding), 5 (passes), and so on — until ten values have been yielded, at which point .first(10) stops asking for more and returns the collected array. The range’s iteration and the select block execution are completely interleaved, one element at a time, driven entirely by how many results the forcing method still needs.
Common Mistakes
Mistake 1: Forgetting .lazy on an infinite sequence
# DON'T: without .lazy, Ruby tries to build an infinite array first — this line never finishes
hangs_forever = (1..Float::INFINITY).select { |n| n % 3 == 0 }.first(5)
puts hangs_forever.inspect
Without .lazy, select is eager: it tries to iterate the entire range and build a full result array before returning anything, and since the range has no end, the program hangs forever and never reaches .first(5). The fix is to insert .lazy right after the range so select becomes a lazy, pull-based step instead:
fixed = (1..Float::INFINITY).lazy.select { |n| n % 3 == 0 }.first(5)
puts fixed.inspect
Output:
[3, 6, 9, 12, 15]
Mistake 2: Forgetting to force the lazy chain
result = [1, 2, 3, 4, 5].lazy.map { |n| n * 10 }
puts result.class
Output:
Enumerator::Lazy
A very common mistake is treating a lazy chain as if it were already an array. result here is still an Enumerator::Lazy object — the multiplication never ran. Code that expects an Array (like passing result to something that calls [] by index, or serializing it to JSON) will misbehave or raise errors. You must call a forcing method such as .to_a, .force, or .first(n) to actually get real values out:
result = [1, 2, 3, 4, 5].lazy.map { |n| n * 10 }.to_a
puts result.inspect
Output:
[10, 20, 30, 40, 50]
Best Practices
- Reach for
.lazywhen working with infinite or unbounded sequences ((1..),(1..Float::INFINITY), or a custom generator), or when chaining severalmap/select-style calls over a large finite collection where you only need a handful of results. - Always end a lazy chain with a forcing method —
first(n),to_a, orforce— or you will never get concrete values back. - Don’t reach for
.lazyon small, already-in-memory collections where you need every result anyway; the plain eager methods are simpler to read and the performance difference is negligible. - Remember that some methods have no lazy equivalent (like
sortorsort_by) because sorting requires seeing every element first; calling one of these on a lazy chain forces full evaluation, so it defeats the purpose on an infinite sequence. - When debugging a lazy pipeline, temporarily insert a
.map { |x| p x; x }step (or useeach_with_index) to see exactly which values are flowing through, since normalputsdebugging inside the chain only prints for elements that are actually pulled. - Use
take_whileinstead ofselectwhen you want to stop at the first element that fails a condition, rather than filtering out failures while scanning past them — it communicates intent more clearly and can be more efficient.
Practice Exercises
- Write a lazy pipeline over
(1..Float::INFINITY)that finds the first 5 perfect squares (1, 4, 9, 16, 25) usingselectandfirst(5). - Using the
prime?helper from Example 3, write a lazy pipeline that returns the first 5 prime numbers greater than 100. Expected output:[101, 103, 107, 109, 113]. - Take the array
%w[apple banana cherry date elderberry fig], chain.lazy.select { |w| w.length > 4 }.map(&:upcase).first(2), and predict the output before running it. Then explain, in your own words, why only two of the words ever get passed throughupcase.
Summary
Enumerable#lazyreturns anEnumerator::Lazythat builds a pipeline of transformations without executing any of them immediately.- Evaluation is pull-based: elements are pulled one at a time from the source and pushed through every step of the chain before the next element is pulled, rather than each step processing the whole collection in turn.
- Lazy chains are essential for infinite sequences (like
(1..Float::INFINITY)) and useful for avoiding wasted intermediate arrays on large finite collections. - Nothing happens until a forcing method —
first(n),to_a,force, oreach— is called; forgetting one leaves you holding an unevaluatedEnumerator::Lazyinstead of real data. - Methods like
sortandsort_byhave no lazy version because they inherently require the full collection, so using them forces complete evaluation and breaks laziness on infinite sequences.
