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:
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:
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:
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:
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:
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:
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:
# 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:
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:
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:
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:
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:
[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:
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:
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
mapto transform,selectto filter, andrejectto exclude. Choose based on which reads most naturally for your condition. each_with_objectis often clearer thanreducewhen building hashes or arrays, since you don't need to return the accumulator each iteration.group_byorganizes data by a computed key;partitionsplits on a boolean condition. Both avoid manual loop-and-hash building.sumandtallyare 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
selectandmaptells the next developer exactly what you're doing—far better than a nested loop.






Comments
Loading comments…