Enumerable's Overlooked Half: The Methods You Should Be Using Instead of each

by Arif Ikhsanudin, Backend Developer

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

Unit Tests That Are Hard to Write Are Telling You Something About Your Code

When a unit test requires elaborate setup, deep mocking, or access to private internals just to exercise a simple behavior, the test is not the problem. The design is. Testability friction is design feedback.

Read more

When Clients Hate Your Work: Learning What Went Wrong

It stings when a client hates what you delivered. Here’s how to turn negative feedback into a roadmap for improvement.

Read more

Feeling Stuck After 3 Years? How to Know if You’re Improving

You’ve been coding for a few years, but it feels… flat. No big jumps, no clear progress—just work on repeat.

Read more

Java Optional — What It's For, What It's Not For, and How to Use It Well

Optional is a return type that signals absence explicitly. It's not a null replacement, not a container to store in fields, and not a way to avoid NullPointerException everywhere. Used correctly, it improves API clarity. Used incorrectly, it adds allocation and verbosity without benefit.

Read more