Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 087 / 100

Enumerable methods in Ruby: patterns, pitfalls and fixes

Expressive collection processing.

Expona Admin8 min read

Introduction

Collection processing in Ruby isn't just about iterating—it's about expressing intent clearly to your team and to your future self. Whether you're filtering user records, transforming API responses, or aggregating telemetry, the methods you choose shape how readable and maintainable your code becomes. Enumerable methods in Ruby offer a rich toolkit for this work, but without understanding their patterns, pitfalls and fixes, we often end up with inefficient loops, nested conditionals, or worse—silent bugs that only surface under load.

Over our years shipping production code in Rails applications, we've seen these patterns misused in ways that cost real performance and clarity. This post walks through the most practical Enumerable methods and shows you not just how to use them, but when and why to reach for each one.

Map, select and reject

These three form the foundation of expressive collection processing. map transforms every element; select keeps only elements matching a condition; reject does the opposite. Simple in theory, but misuse is rampant.

A common real-world scenario: you're loading active user records and want their public display names and IDs. The beginner approach often looks like this:

ruby
users = User.where(active: true)
result = []
users.each do |user|
  result << { id: user.id, name: user.display_name }
end

This works, but it's verbose and doesn't signal intent. The Enumerable approach is clearer:

ruby
User.where(active: true).map { |user| { id: user.id, name: user.display_name } }

Better still, in Rails we often chain directly with ActiveRecord methods. Consider filtering premium users by a subscription attribute, then extracting their emails:

ruby
premium_emails = User.where(premium: true)
  .select { |user| user.subscription_active? }
  .map(&:email)

Note the use of &:email—this is shorthand for { |user| user.email } and cleaner when you're just calling a method with no arguments. However, we've seen teams overuse this syntax with complex logic inside, which defeats the readability gain.

For reject, consider users you want to exclude—inactive accounts or those with spam flags:

ruby
active_users = all_users.reject { |user| user.spam_flagged? || user.inactive? }

This reads naturally: "reject users that are spam flagged or inactive." The inverse, using select, would be:

ruby
active_users = all_users.select { |user| !user.spam_flagged? && !user.inactive? }

The negation makes this harder to parse. Choose the one that makes your condition flow naturally.

Each_with_object and reduce

When you need to accumulate results across iterations, reduce and each_with_object let you thread state through your processing pipeline without manual variable management.

reduce is classic. Sum an array of prices, or build a hash of grouped data:

ruby
order_items = [
  { sku: 'WIDGET', price: 10.00, qty: 2 },
  { sku: 'GADGET', price: 5.00, qty: 3 }
]

total = order_items.reduce(0) { |sum, item| sum + item[:price] * item[:qty] }
# => 35.0

The accumulator (here, sum) starts at the value you pass—0 in this case—and the block receives it on each iteration, returning the new accumulated value.

each_with_object is less commonly known but often more readable when your accumulator is a hash or array. The key difference: the accumulator is the second block parameter and is mutated in place, so you don't return it each iteration:

ruby
# Using reduce to build a hash
order_summary = order_items.reduce({}) do |acc, item|
  acc[item[:sku]] = item[:qty]
  acc
end

# Using each_with_object (cleaner for this pattern)
order_summary = order_items.each_with_object({}) do |item, acc|
  acc[item[:sku]] = item[:qty]
end

The second version avoids the "return the accumulator" dance. When building complex structures, this clarity compounds. Here's a real example: building a customer activity report where you group by date and count actions:

ruby
activities = [
  { user_id: 1, action: 'login', created_at: Date.today },
  { user_id: 2, action: 'purchase', created_at: Date.today },
  { user_id: 1, action: 'logout', created_at: Date.today }
]

report = activities.each_with_object({}) do |activity, acc|
  date = activity[:created_at]
  acc[date] ||= { logins: 0, purchases: 0, logouts: 0 }
  acc[date][activity[:action].to_sym] += 1
end

# report[Date.today] => { logins: 1, purchases: 1, logouts: 1 }

With reduce, you'd need to remember to return acc at the end of every block. With each_with_object, the mutation is explicit and expected.

Group_by and partition

group_by organizes elements into a hash of arrays based on a block's return value. It's invaluable for categorization without writing explicit hash-building loops.

Imagine you're processing a batch of orders and want to separate them by fulfillment status:

ruby
orders = [
  { id: 1, status: 'pending' },
  { id: 2, status: 'shipped' },
  { id: 3, status: 'pending' },
  { id: 4, status: 'delivered' }
]

by_status = orders.group_by { |order| order[:status] }

# by_status = {
#   'pending' => [{id: 1, ...}, {id: 3, ...}],
#   'shipped' => [{id: 2, ...}],
#   'delivered' => [{id: 4, ...}]
# }

You can now iterate over statuses directly: by_status['pending'] gives all pending orders. This is far cleaner than manually checking and pushing into nested hashes.

partition is a specialized grouper: it splits a collection into exactly two arrays—those matching a condition and those that don't. It returns an array of two arrays, perfect when you need a binary split:

ruby
pending, non_pending = orders.partition { |order| order[:status] == 'pending' }

pending # => [{id: 1, ...}, {id: 3, ...}]
non_pending # => [{id: 2, ...}, {id: 4, ...}]

This reads more clearly than select and reject when you need both results. And destructuring into two variables makes the intent unmistakable—your team immediately knows this is a binary split.

Sum and tally

sum aggregates numeric values. Unlike the manual reduce(0) pattern, it's optimized and handles edge cases gracefully:

ruby
order_items = [
  { price: 10.00, qty: 2 },
  { price: 5.00, qty: 3 }
]

# Manual reduce approach
total = order_items.reduce(0) { |acc, item| acc + item[:price] * item[:qty] }

# Using sum with a block
total = order_items.sum { |item| item[:price] * item[:qty] }

The sum version is shorter and carries clearer intent: "sum the result of this calculation." For simple numeric arrays, it's even simpler:

ruby
[1, 2, 3].sum # => 6
[].sum # => 0 (doesn't error; returns the starting value)

tally counts occurrences of each element, returning a hash of element => count. It's a shortcut for the common "count occurrences" pattern:

ruby
events = ['click', 'view', 'click', 'purchase', 'view', 'view']

# Manual approach with reduce or group_by
counts = events.group_by { |e| e }.transform_values(&:size)
# or
counts = events.reduce({}) { |h, e| h[e] = h[e].to_i + 1; h }

# Using tally (Rails 6.1+, Ruby 2.7+)
counts = events.tally
# => { 'click' => 2, 'view' => 3, 'purchase' => 1 }

tally is purpose-built for this and reads immediately. In a real service, imagine counting event types from a stream of activity logs before sending them to analytics:

ruby
activities = Activity.where(created_at: 1.hour.ago..).pluck(:event_type)
summary = activities.tally
# Send to metrics service: { page_view: 120, purchase: 15, error: 3 }

Common mistakes

Chaining too many operations: While .map { }.select { }.map { } is valid, it iterates the collection three times. If performance matters, combine logic or use each_with_object to pass through once.

Mutating during iteration: Don't modify the original collection inside a block. This causes unpredictable behavior, especially with each. If you need to filter and transform, use map and select; don't push to a side array.

Forgetting nil handling: Collections often contain nils. A block like { |item| item.field } will crash if item is nil. Use safe navigation &. or guard with compact first.

Over-using symbol-to-proc: &:method_name is clean for simple method calls, but resist the urge to nest complex logic. Keep blocks readable.

Misunderstanding reduce with no initial value: [1, 2, 3].reduce(:+) starts with the first element (1) and applies the operator to subsequent ones. Without an initial value, the first element becomes the accumulator. This catches people off-guard with empty arrays or non-numeric types.

Key takeaways

  • Use map to transform, select to filter, and reject to exclude. Choose based on which reads most naturally for your condition.
  • each_with_object is often clearer than reduce when building hashes or arrays, since you don't need to return the accumulator each iteration.
  • group_by organizes data by a computed key; partition splits on a boolean condition. Both avoid manual loop-and-hash building.
  • sum and tally are purpose-built aggregators that read clearly and handle edge cases. Prefer them over hand-rolled reduce patterns.
  • Chain operations sparingly. If you're iterating the collection multiple times, consider consolidating with a single pass using each_with_object.
  • Enumerable methods express intent clearly to your team. A readable chain of select and map tells the next developer exactly what you're doing—far better than a nested loop.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles