Hash Methods
A Hash is Ruby’s built-in key-value data structure — what other languages call a dictionary, map, or associative array. Every key maps to exactly one value, keys can be any object (though symbols and strings are the overwhelming favorites), and since Ruby 1.9 a Hash also remembers the order its keys were inserted in. Hashes are everywhere in idiomatic Ruby: keyword arguments are hashes under the hood, JSON parses straight into hashes, and configuration objects are almost always hashes. This lesson covers the full toolkit for building, querying, transforming, and safely handling them.
Overview: How Hashes Work
Internally, a Hash stores each key alongside a computed hash code (produced by calling hash on the key) and groups entries into buckets so that looking up a value is, on average, an O(1) operation no matter how many entries the hash holds — dramatically faster than scanning an Array for a matching element. This is why keys need a stable hash and eql? implementation, and why mutating a String after using it as a key can silently break lookups: the stored hash code no longer matches the mutated string. That’s one reason symbols, which are immutable, are the conventional choice for hash keys.
Two hashes are == when they contain the same key-value pairs, regardless of insertion order. equal? instead checks whether they are the literal same object in memory — a much stricter question. Because nil and false are Ruby’s only falsy values, writing if hash[:key] conflates two different situations: ‘the key is missing’ and ‘the key maps to false’. Ruby gives you key? (aliased has_key?) and fetch specifically to tell those apart.
Every Hash also has a default value or default proc, controlling what hash[missing_key] returns when the key isn’t present. By default that’s nil, but Hash.new(0) or Hash.new { |h, k| h[k] = [] } are common idioms for tallying and grouping — covered in Common Mistakes below, because the object-identity version of the default has a sharp edge.
Syntax
Ruby offers two literal forms for building a Hash, plus the class constructor and its default-value variants:
{ "name" => "Ada", "age" => 36 }
{ name: "Ada", age: 36 }
Hash.new
Hash.new(0)
Hash.new { |hash, key| hash[key] = [] }
hash[key]
hash[key] = value
| Form | Meaning |
|---|---|
key => value |
The general “hash rocket” syntax; works with any object as a key. |
key: value |
Shorthand available only when the key is a symbol; equivalent to :key => value. |
Hash.new |
An empty hash whose missing keys return nil. |
Hash.new(default) |
Missing keys return the exact same default object every time. |
Hash.new { |hash, key| ... } |
A default proc, run fresh for each missing key. |
hash[key] |
Reads the value for key, or the default if it’s absent. |
hash[key] = value |
Stores value under key, overwriting any existing entry. |
Beyond the literal syntax, Hash includes the Enumerable module, which is where most of its power comes from:
| Method | What it does |
|---|---|
each / each_pair |
Iterates [key, value] pairs; returns the original hash. |
map |
Returns an Array built from the block’s return values (not a Hash). |
select / filter |
Returns a new Hash keeping only pairs where the block is truthy. |
reject |
Returns a new Hash keeping only pairs where the block is falsy. |
transform_values |
Returns a new Hash with the same keys and each value passed through the block. |
transform_keys |
Returns a new Hash with the same values and each key passed through the block. |
merge / merge! |
Combines two hashes; an optional block resolves key collisions. |
fetch |
Reads a value; raises KeyError (or returns a default/block value) if the key is missing. |
dig |
Safely reads a value several levels deep, returning nil if any step is missing. |
key? / include? / member? |
Returns true if the key exists, regardless of its value. |
delete |
Removes a key and returns its value (or nil). |
reduce / inject |
Folds all pairs down to a single accumulated value. |
each_with_object |
Iterates while building up a separate accumulator object. |
sort_by |
Returns an Array of pairs sorted by the block’s result. |
Examples
Example 1: Creating, Reading, and Iterating
This example builds a small hash and exercises the most basic read operations.
inventory = { apples: 10, bananas: 5, cherries: 20 }
puts inventory[:apples]
puts inventory.fetch(:bananas)
puts inventory.fetch(:grapes, 0)
puts inventory.key?(:cherries)
puts inventory.keys.inspect
puts inventory.values.inspect
puts inventory.size
inventory.each do |fruit, count|
puts "#{fruit}: #{count}"
end
Output:
10
5
0
true
[:apples, :bananas, :cherries]
[10, 5, 20]
3
apples: 10
bananas: 5
cherries: 20
inventory[:apples] reads directly. fetch(:bananas) does the same thing for a present key, but fetch(:grapes, 0) shows its real value: since :grapes isn’t in the hash, it falls back to the explicit default 0 instead of silently returning nil. each yields the key and value as a two-element pair on every iteration, in insertion order.
Example 2: Transforming a Hash — map, select, merge, reduce
Hashes are rarely just read from; they’re usually transformed into a new hash. None of the methods below mutate prices.
prices = { apple: 1.5, banana: 0.5, cherry: 3.0 }
discounted = prices.transform_values { |price| (price * 0.9).round(2) }
discounted.each { |fruit, price| puts "#{fruit}: $#{price}" }
expensive = prices.select { |_name, price| price > 1.0 }
puts expensive.keys.inspect
combined = prices.merge({ date: 4.0, apple: 2.0 }) { |_key, old_val, new_val| old_val + new_val }
combined.each { |fruit, price| puts "#{fruit}: $#{price}" }
total = prices.reduce(0) { |sum, (_name, price)| sum + price }
puts total
Output:
apple: $1.35
banana: $0.45
cherry: $2.7
[:apple, :cherry]
apple: $3.5
banana: $0.5
cherry: $3.0
date: $4.0
5.0
transform_values keeps every key and only rewrites the values. select keeps whole pairs whose block returns something truthy. merge combines prices with a second hash; because :apple exists in both, the block runs and decides the winner (1.5 + 2.0), while :date, which only exists in the argument, is simply added. Notice the key order: the receiver’s original keys come first, then any brand-new keys from the argument are appended at the end. Finally, reduce(0) folds all the original prices down to a single sum, proving prices itself was never touched by any of the earlier calls.
Example 3: Nested Hashes, dig, and Default Values
students = {
"alice" => { age: 22, grades: [90, 85, 88] },
"bob" => { age: 24, grades: [70, 75] }
}
puts students.dig("alice", :age)
puts students.dig("carol", :age).inspect
average_grades = students.each_with_object({}) do |(name, info), result|
result[name] = info[:grades].sum / info[:grades].size.to_f
end
average_grades.each { |name, avg| puts "#{name}: #{avg.round(2)}" }
tally = Hash.new(0)
%w[a b a c b a].each { |letter| tally[letter] += 1 }
tally.each { |letter, count| puts "#{letter} => #{count}" }
Output:
22
nil
alice: 87.67
bob: 72.5
a => 3
b => 2
c => 1
dig("alice", :age) walks two levels deep and returns 22. dig("carol", :age) fails at the very first step — "carol" isn’t a key — so dig returns nil instead of raising, which is exactly why it’s safer than chaining students["carol"][:age] by hand. each_with_object({}) builds a brand-new hash of averages while iterating, without a separate variable declared beforehand. The final block uses Hash.new(0): every previously-unseen letter starts from the default 0, so tally[letter] += 1 works immediately without an existence check.
How It Works, Step by Step
Take combined = prices.merge({ date: 4.0, apple: 2.0 }) { |_key, old_val, new_val| old_val + new_val } from Example 2. Ruby first duplicates prices into a new hash. Then, for every key in the argument hash, in that hash’s own order: if the key is not already present in the copy, it’s simply inserted with its value; if the key is already present (as :apple is), the block is called with the key, the copy’s existing value, and the argument’s value, and whatever the block returns becomes the final value for that key. Only after every argument key has been processed does merge return the finished hash — the original prices is never touched, which is why calling prices.reduce afterward still sees the original three entries.
The same discipline applies to each: Ruby walks the hash’s internal entries in the order they were first inserted, yielding a [key, value] pair (or two separate block arguments, thanks to Ruby’s automatic array destructuring) on every step, and returns the receiver itself once the last pair has been yielded.
Common Mistakes
Mistake 1: Sharing one mutable default object
Hash.new(some_object) uses the exact same object as the default for every missing key — and reading a missing key with [] does not store anything in the hash, it just returns the default.
groups = Hash.new([])
groups[:fruits] << "apple"
groups[:veggies] << "carrot"
puts groups[:fruits].inspect
puts groups[:veggies].inspect
Output:
["apple", "carrot"]
["apple", "carrot"]
Both lines print the same combined array. groups[:fruits] never actually created a :fruits key; it returned the one shared default array, which << then mutated. The second lookup returns that same shared array again, now containing both fruits and veggies. Use a default proc instead, which runs fresh for every new key and assigns the result into the hash:
groups = Hash.new { |hash, key| hash[key] = [] }
groups[:fruits] << "apple"
groups[:veggies] << "carrot"
puts groups[:fruits].inspect
puts groups[:veggies].inspect
Output:
["apple"]
["carrot"]
Now each missing key triggers the block, which builds a brand-new array and stores it under that specific key before returning it.
Mistake 2: Letting a typo’d key return a silent nil
[] returns nil for any key that isn’t present — including one that’s just misspelled — and Ruby won’t warn you.
config = { timeout: 30, retries: 3 }
timeout = config[:timout] || 10
puts "Timeout is #{timeout} seconds"
Output:
Timeout is 10 seconds
The typo :timout silently returns nil, the || 10 fallback kicks in, and the program runs without ever raising an error — quietly ignoring the real, correctly-spelled setting of 30. fetch is the fix: it raises a KeyError immediately for any key you didn’t explicitly allow a default for.
config = { timeout: 30, retries: 3 }
timeout = config.fetch(:timeout)
puts "Timeout is #{timeout} seconds"
Output:
Timeout is 30 seconds
Mistake 3: Adding a key while iterating
Inserting a brand-new key into a hash while you’re in the middle of iterating it with each raises RuntimeError: can't add a new key into hash during iteration.
counts = { a: 1, b: 2, c: 3 }
counts.each do |key, value|
puts "#{key}: #{value}"
counts[:d] = 4 if key == :a
end
Output (before it crashes):
a: 1
The loop prints a: 1, then tries to insert :d while still iterating and Ruby raises immediately, so b: 2 and c: 3 never print. The safe pattern is to collect the changes separately and apply them once the iteration has fully finished:
counts = { a: 1, b: 2, c: 3 }
additions = {}
counts.each do |key, value|
puts "#{key}: #{value}"
additions[:d] = 4 if key == :a
end
counts.merge!(additions)
counts.each { |key, value| puts "final #{key}: #{value}" }
Output:
a: 1
b: 2
c: 3
final a: 1
final b: 2
final c: 3
final d: 4
Best Practices
- Prefer symbol keys (
:name) over string keys for internal identifiers; reserve strings for genuinely user-facing text or data that already arrived as text, like parsed JSON. - Use
fetchinstead of[]whenever a missing key should be a loud error rather than a silentnil— especially for configuration and required arguments. - Reach for
Hash.new { |h, k| h[k] = ... }instead ofHash.new(some_object)whenever the default needs to be a new, independent object for each key. - Use
digto read nested structures safely instead of chaining[]calls that raiseNoMethodErrorthe moment an intermediate value isnil. - Favor
transform_values,transform_keys,select,reject,merge, andeach_with_objectover hand-rolled loops that build a new hash — they say what you mean and avoid iteration bugs. - Never mutate a hash while iterating it with
each; collect the changes separately and apply them afterward withmerge!. - Remember that
mergeandselectreturn new hashes, whilemerge!andselect!mutate in place and can returnnilif nothing changed — check which one you’re calling before chaining another method on the result.
Practice Exercises
- Write a method
word_frequency(text)that takes a string and returns a hash mapping each lowercase word to how many times it appears. Hint: split the string on whitespace and useHash.new(0)to tally. For"the cat sat on the mat","the"should map to2. - Given
scores = { "Sam" => 72, "Amy" => 95, "Lee" => 88 }, useselectandsort_byto print only the students who scored 80 or above, from highest score to lowest. - Write a method
safe_deep_get(hash, *keys)that usesdigto read a value several levels deep in a nested hash, returningnilinstead of raising if any key along the way is missing. Test it against a hash where one of the intermediate keys doesn’t exist.
Summary
- A Hash stores unique keys mapped to values and, since Ruby 1.9, preserves insertion order.
- Looking up a value by key is fast (amortized O(1)) because Ruby hashes the key internally rather than scanning every entry.
- Use
{ key: value }shorthand for symbol keys, andkey => valuefor any other kind of key. fetchandkey?distinguish a missing key from a key mapped tonilorfalse;digsafely reads nested structures.transform_values,transform_keys,select,reject,merge, andeach_with_objectbuild new hashes without manual loops.Hash.new(default)shares one default object across all missing keys;Hash.new { |h, k| ... }creates a fresh one per key — use the block form for mutable defaults like arrays or hashes.- Never insert a new key into a hash while iterating over it with
each.
