Lazy Enumerators

Published by Programming Line in Ruby. Last updated .

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 .lazy when working with infinite or unbounded sequences ((1..), (1..Float::INFINITY), or a custom generator), or when chaining several map/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, or force — or you will never get concrete values back.
  • Don’t reach for .lazy on 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 sort or sort_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 use each_with_index) to see exactly which values are flowing through, since normal puts debugging inside the chain only prints for elements that are actually pulled.
  • Use take_while instead of select when 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) using select and first(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 through upcase.

Summary

  • Enumerable#lazy returns an Enumerator::Lazy that 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, or each — is called; forgetting one leaves you holding an unevaluated Enumerator::Lazy instead of real data.
  • Methods like sort and sort_by have no lazy version because they inherently require the full collection, so using them forces complete evaluation and breaks laziness on infinite sequences.