Hash.new With Default Values — Ruby's Underused Shortcut
Most Ruby developers initialize hashes with {} and manually check for missing keys before updating them. It works, but Ruby’s Hash.new offers a more expressive alternative: hashes that return a default value for any missing key — or even automatically create and store a value for each new key. Once you’ve used this pattern, you’ll spot the opportunities for it constantly.
The Simple Default Value
Hash.new(default) creates a hash that returns default for any key that doesn’t exist, instead of nil:
Example:
# Without default — manual nil guard everywhere
counts = {}
counts[:apples] = (counts[:apples] || 0) + 1
counts[:apples] = (counts[:apples] || 0) + 1
counts[:oranges] = (counts[:oranges] || 0) + 1
# With Hash.new(0) — clean accumulation
counts = Hash.new(0)
counts[:apples] += 1
counts[:apples] += 1
counts[:oranges] += 1
counts # => { apples: 2, oranges: 1 }
The missing key returns 0 instead of nil, so += 1 works without a guard. The key only appears in the hash once you assign to it — just accessing doesn’t create it.
Example:
inventory = Hash.new(0)
inventory[:widget] # => 0
inventory # => {} — NOT { widget: 0 }
inventory[:widget] += 5
inventory # => { widget: 5 }
This distinction matters: the default value is returned on access but not stored. The key only appears after assignment.
The Default Block — Dynamic Defaults
Hash.new { |hash, key| ... } takes a block instead of a static value. The block receives the hash and the missing key, and its return value is used as the default. Crucially, you can assign inside the block to store the default:
Example:
# Auto-create an empty array for each new key
grouped = Hash.new { |h, k| h[k] = [] }
grouped[:fruits] << "apple"
grouped[:fruits] << "banana"
grouped[:veggies] << "carrot"
grouped
# => { fruits: ["apple", "banana"], veggies: ["carrot"] }
Compare to the verbose alternative:
Example:
# Without block default — manual initialization
grouped = {}
grouped[:fruits] ||= []
grouped[:fruits] << "apple"
grouped[:fruits] ||= []
grouped[:fruits] << "banana"
The block form eliminates all the ||= [] setup. Each new key automatically gets an empty array that’s stored in the hash.
Nested Hash Auto-Vivification
The block default enables auto-vivification — deeply nested hashes that create intermediate levels automatically:
Example:
# Two-level auto-vivification
stats = Hash.new { |h, k| h[k] = Hash.new(0) }
stats[:api][:requests] += 1
stats[:api][:errors] += 1
stats[:db][:queries] += 5
stats
# => { api: { requests: 1, errors: 1 }, db: { queries: 5 } }
No need to initialize stats[:api] before accessing stats[:api][:requests]. The outer hash creates an inner Hash.new(0) automatically when a new top-level key is accessed.
For arbitrary depth:
Example:
# Infinitely deep auto-vivification
deep = Hash.new { |h, k| h[k] = Hash.new(&h.default_proc) }
deep[:a][:b][:c][:d] = "found it"
deep # => { a: { b: { c: { d: "found it" } } } }
h.default_proc passes the same block recursively, so each level gets the same auto-vivifying behavior.
Practical Pattern: Grouping Without group_by
Example:
orders = [
{ id: 1, status: "pending", total: 50 },
{ id: 2, status: "shipped", total: 80 },
{ id: 3, status: "pending", total: 30 },
{ id: 4, status: "delivered", total: 120 }
]
by_status = Hash.new { |h, k| h[k] = [] }
orders.each { |o| by_status[o[:status]] << o }
by_status["pending"].sum { |o| o[:total] } # => 80
by_status["shipped"].length # => 1
This is equivalent to orders.group_by { |o| o[:status] } for simple grouping, but the block default gives you more control when you need to transform values while grouping.
Counting with tally vs Hash.new(0)
For simple occurrence counting, Ruby 2.7+ has tally which is more expressive:
Example:
words = ["ruby", "rails", "ruby", "tips", "ruby", "rails"]
# tally — idiomatic for counting
words.tally
# => { "ruby" => 3, "rails" => 2, "tips" => 1 }
# Hash.new(0) — more flexible when counting isn't the only operation
counts = Hash.new(0)
words.each do |word|
counts[word] += 1
log_word(word) if counts[word] == 1 # first occurrence
end
Use tally when you’re only counting. Use Hash.new(0) when you need to do more than count in the same pass.
Pro-Tip: One gotcha with static default values:
Hash.new([])shares the same array object across all missing keys. Mutating it affects every key’s “default.” Always use the block form when your default is a mutable object:Hash.new { |h, k| h[k] = [] }creates a new array per key.
Example:
# BUG — shared array
bad = Hash.new([])
bad[:a] << 1
bad[:b] << 2
bad[:a] # => [1, 2] — they share the same array!
# Correct — separate array per key
good = Hash.new { |h, k| h[k] = [] }
good[:a] << 1
good[:b] << 2
good[:a] # => [1]
Conclusion
Hash.new with a default value or block is one of Ruby’s most practical small features. It eliminates defensive ||= initialization patterns, enables auto-vivification for nested structures, and makes accumulation and grouping code dramatically cleaner. The gotcha around mutable defaults is worth internalizing once — after that, the pattern just becomes part of how you write Ruby.
FAQs
Q1: Does the default value affect hash.key? or hash.include??
No. hash.key?(:missing) returns false even if the hash has a default value — the key genuinely isn’t there. The default only affects the return value of hash[:missing], not membership checks.
Q2: Can I add a default to an existing hash?
Yes: hash.default = 0 sets a static default. hash.default_proc = proc { |h, k| h[k] = [] } sets a block default. Both work on any hash instance.
Q3: What does fetch do with default values?
hash.fetch(:missing) raises KeyError regardless of the hash’s default. fetch explicitly bypasses the default. Use fetch(key, fallback) when you want a specific fallback for a specific fetch, independent of the hash’s default.
Q4: Are hashes with defaults serializable to JSON?
The default value/block doesn’t serialize — only the stored key-value pairs do. JSON.generate(hash) produces normal JSON. The default behavior is lost on deserialization, so don’t rely on it surviving a JSON round-trip.
Q5: How do I convert a Hash.new(default) hash to a regular hash?
hash.to_h or hash.dup creates a copy with the same key-value pairs. Hash[hash] also works. None of these preserve the default — the result is a standard hash with nil for missing keys.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀