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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Email Templates for Junior Contractors Who Don’t Know What to Say

Sending emails as a junior contractor can feel like walking a tightrope. These simple templates make communication easier, without sounding stiff or fake.

Read more

A Good API Is One Developers Never Have to Ask Questions About

APIs fail when they require interpretation instead of execution. The best APIs eliminate ambiguity through consistent design, predictable behavior, and self-evident contracts.

Read more

Dell, Apple, Tesla Are in Austin — and They Are Hiring the Same Developers You Need

When the biggest companies in the world set up in your city, the hiring market doesn't get easier. Here's how startups are staying in the game.

Read more

Stop Returning Everything When the Client Only Needs a Few Fields

Over-fetching is a performance problem and a data leakage problem. Sparse fieldsets and response projection are the tools that solve it.

Read more