Enumerable's Overlooked Half: The Methods You Should Be Using Instead of each
by Eric Hanson, Backend Developer at Clean Systems Consulting
The symptom
You're reading a PR and you see this:
counts = Hash.new(0)
events.each { |e| counts[e.type] += 1 }
counts
Or this:
result = []
records.each do |r|
r.tags.each { |t| result << t }
end
result
Or a chain of .select followed by .map followed by .first. These patterns aren't wrong, but they're all reinventing methods that already exist and are faster to read once you know them. Here's the half of Enumerable that most developers have never opened.
tally — stop writing frequency hashes by hand
tally (Ruby 2.7+) counts occurrences of each element and returns a hash:
events.map(&:type).tally
# => { "click" => 42, "view" => 108, "purchase" => 7 }
In Ruby 3.1, tally accepts a hash argument to accumulate into an existing one — useful when you're processing records in batches and need a running count across iterations.
running = Hash.new(0)
batch_one.map(&:type).tally(running)
batch_two.map(&:type).tally(running)
running # => merged counts across both batches
flat_map — the nested array flattener
flat_map maps and flattens one level in a single pass. It's faster than .map { }.flatten(1) because it avoids the intermediate array allocation:
# Before
result = []
orders.each { |o| o.line_items.each { |li| result << li } }
# After
line_items = orders.flat_map(&:line_items)
The one-level flatten is deliberate. flat_map does not recursively flatten. If your data is nested more than one level, you want flatten(n) explicitly — don't reach for flat_map hoping it'll sort out arbitrary depth.
each_with_object — building a result without a temp variable
each_with_object passes an accumulator object through every iteration and returns it at the end. Cleaner than inject/reduce for mutable accumulation:
# Building a lookup hash
users.each_with_object({}) do |user, index|
index[user.id] = user
end
Compare to the reduce version, which requires you to return the accumulator from every block — a footgun when the last expression isn't the hash:
# This breaks silently if you add a side effect after the assignment
users.reduce({}) do |index, user|
index[user.id] = user # returns the user, not index — bug
index # easy to forget this
end
each_with_object avoids that entirely. The accumulator is the return value regardless of what the block returns.
chunk_while — grouping consecutive elements
chunk_while groups consecutive elements for which the block returns true. The canonical use case is run-length encoding or grouping sequential records:
# Group consecutive integers into runs
[1, 2, 3, 7, 8, 9, 10, 15].chunk_while { |a, b| b == a + 1 }.to_a
# => [[1, 2, 3], [7, 8, 9, 10], [15]]
A more practical example: grouping sorted log entries into sessions where consecutive entries are within 30 minutes of each other:
sorted_events
.chunk_while { |a, b| b.timestamp - a.timestamp < 1800 }
.map { |session| Session.new(events: session) }
chunk (without _while) is the counterpart for grouping by a key value rather than a relationship between adjacent elements. Use chunk when you have a category; use chunk_while when you have a boundary condition.
tally's cousin: group_by
group_by partitions a collection into a hash of arrays, keyed by the block's return value:
orders.group_by(&:status)
# => { "pending" => [...], "shipped" => [...], "cancelled" => [...] }
This replaces a very common each-with-hash pattern. The difference from tally: group_by keeps the elements; tally keeps the counts.
zip — pairing parallel collections
zip is underused and saves significant noise when you have two aligned arrays you need to process together:
keys = [:name, :email, :role]
values = ["Alice", "alice@example.com", "admin"]
keys.zip(values).to_h
# => { name: "Alice", email: "alice@example.com", role: "admin" }
zip truncates to the length of the receiver. If values is shorter than keys, missing positions are filled with nil. That's usually fine for parallel data you control; watch out when combining external datasets.
min_by / max_by — stop sorting to get one element
Calling .sort_by { }.first to find the minimum is O(n log n) when you need O(n). min_by is the right tool:
# Wasteful
orders.sort_by(&:total).first
# Correct
orders.min_by(&:total)
Both min_by and max_by accept an integer argument (Ruby 2.2+) to return multiple elements:
orders.min_by(3, &:total) # the three cheapest orders
Lazy enumerators — stop allocating intermediate arrays
Chained enumerable calls allocate an intermediate array at each step:
records
.select { |r| r.active? } # allocates array 1
.map { |r| r.to_summary } # allocates array 2
.first(10)
If records has 50,000 elements and you only need 10, you've done 50,000 iterations of select and up to 50,000 of map before discarding almost everything.
lazy short-circuits the chain:
records
.lazy
.select { |r| r.active? }
.map { |r| r.to_summary }
.first(10)
With lazy, Ruby pulls elements through the entire chain one at a time and stops as soon as it has 10. Total iterations: as many as it takes to find 10 active records, not 50,000.
The tradeoff: lazy returns an Enumerator::Lazy, not an array. Calling to_a at the end forces evaluation. For short collections (hundreds of elements), the overhead of the lazy wrapper isn't worth it — measure before defaulting to it everywhere.
The audit worth running
Pull up a file in your codebase with heavy iteration logic and search for each. For every instance, ask: is this building a hash (each_with_object, group_by, tally), flattening nested collections (flat_map), grouping consecutive elements (chunk_while), or pairing parallel data (zip)? If yes, there's a cleaner replacement.
The payoff isn't micro-optimization. It's that these methods telegraph intent. tally tells the next developer exactly what the code does in one word. A Hash.new(0) accumulator loop makes them reconstruct it.