April 29, 2025
Since Ruby 2.7, the Enumerable#tally method has provided a clean and elegant way to count element occurrences in an enumerable. While it’s an excellent addition for code readability, many Rubyists have noticed that in performance-critical scenarios, it tends to be slower than the classic each_with_object approach.
In this article, we’ll explore why tally is slower, what’s happening under the hood, and when it makes sense to use one over the other.
What Does tally Do?
['a', 'b', 'a', 'c', 'b'].tally
# => {"a"=>2, "b"=>2, "c"=>1}
Ruby’s tally method counts how many times each element appears and returns a hash with frequencies. Under the hood, it’s implemented in C and is available to any Enumerable.
It’s perfect for clarity and short scripts — but not always the most performant choice.
Want to Improve Your Application’s Performance?
If you’re looking to optimize your Ruby applications and boost performance, get in touch with us! Whether it’s for refining your code, enhancing efficiency, or troubleshooting slowdowns, we’re here to help.
Get in Touch with Ruby Stack News
The Benchmark: tally vs each_with_object
Let’s compare tally with the old-school approach:
text = "a" * 10_000
Benchmark.bm do |x|
x.report("tally:") do
text.chars.tally
end
x.report("each_with_object:") do
text.chars.each_with_object(Hash.new(0)) { |char, hash| hash[char] += 1 }
end
end
Typical output:
tally: 0.00811s
each_with_object: 0.00526s
It’s not night and day, but it’s measurable — and for high-throughput loops or large data, this adds up.
Why tally Is Slower: Under the Hood
1. Generic vs Specific
tally is designed to work with any enumerable — arrays, ranges, even custom classes. To support this, it does more internal checks (e.g., type coercion, key handling) than your custom one-liner does.
2. Extra Hash Allocation
Internally, tally allocates a hash and populates it as it iterates — same as each_with_object, but with extra method lookups and type safety logic, especially for exotic keys or overridden equality methods.
3. Lack of JIT Optimization
The Ruby JIT (and interpreter) optimizes tight Ruby blocks well. It doesn’t inline or optimize C-extension code in the same way — so your Ruby each_with_object block is more likely to benefit from inline caching or fast-path optimizations.
When to Use tally
- When readability and brevity are more important than raw speed.
- When working with small data or one-off scripts.
- When counting things like symbols, words, or groupings where performance isn’t critical.
When to Prefer each_with_object
- In performance-critical code or hot loops.
- When you’re counting within larger data pipelines.
- When writing code that needs to squeeze out extra performance.
Custom Optimized Tally (if you’re curious)
Here’s a stripped-down version of tally that you can tweak for max speed:
def tally_fast(enum)
hash = {}
enum.each do |item|
hash[item] = (hash[item] || 0) + 1
end
hash
end
It skips some niceties but works well if you know your inputs.
Final Thoughts
Ruby’s tally is a wonderful method for clarity — but just like map vs each, or select vs find_all, knowing the trade-offs helps you write better code.
Use tally for expressiveness. Reach for each_with_object when performance matters.
Top comments (0)