Hashes
A Hash is Ruby’s built-in associative array: a collection of unique keys mapped to values, much like a dictionary or map in other languages. Hashes show up everywhere in idiomatic Ruby — configuration options, parsed JSON, grouped data, and keyword-style method arguments all pass through a Hash at some point. Understanding how hashes store and look up data, and where their sharp edges are, is essential for writing correct, efficient Ruby.
Overview: How Hashes Work
In Ruby, a Hash is an object — an instance of the Hash class. Nearly everything in Ruby is an object, even nil and integers, which is why you can call methods like .merge, .select, or .each directly on a hash literal without any special syntax.
Under the hood, a hash uses each key’s hash method to compute an integer "hash code," which tells Ruby which internal bucket that key-value pair belongs to. When you look up hash[key], Ruby computes the hash code of the key you passed in, jumps straight to the matching bucket, and then confirms an exact match using eql? (a stricter, type-sensitive cousin of ==). This bucket-based approach is what makes lookup, insertion, and deletion run in roughly constant time — O(1) on average — no matter how many entries the hash holds, unlike scanning an array, which is O(n).
Since Ruby 1.9, hashes also remember the order keys were first inserted, and every iteration method (each, keys, map, and so on) walks the hash in that order, not some unpredictable internal bucket order. That combination — fast lookup plus predictable order — is why hashes are used constantly for things like JSON documents and configuration.
Keys can be any object that implements hash and eql? sensibly — strings, symbols, integers, even arrays — but in practice Ruby developers almost always use symbols (:status) for keys that identify something internally. Symbols are immutable, and every occurrence of the same symbol in a program is the exact same object in memory, which makes them cheaper to hash and compare than an equivalent string. Reserve string keys for values that are genuinely arbitrary text, such as data parsed from JSON or typed by a user.
Syntax
A hash can be built as a literal, via Hash.new, or by converting another collection with to_h. The two most common literal forms are the "hash rocket" form, which works with any key type, and the shorthand form for symbol keys:
{ key1 => value1, key2 => value2, ... }
{ key1: value1, key2: value2 }
Hash.new(default_value)
Hash.new { |hash, key| ... }
hash[key]
hash[key] = value
- Hash rocket (
=>) — required when a key isn’t a symbol, e.g.{ "France" => "Paris" }. - Symbol shorthand (
key:) — equivalent to:key => value; only valid when the key is a symbol. Hash.new(default_value)— creates an empty hash where reading a missing key returnsdefault_valueinstead ofnil; the key itself is never actually stored.Hash.new { |hash, key| ... }— the block form computes a default for each missing key and can store a fresh object into the hash on the spot.
The table below covers the methods you’ll reach for most often:
| Method | Description |
|---|---|
hash[key] |
Look up a value by key; returns nil if the key is absent. |
hash.fetch(key) |
Look up a value; raises KeyError if missing (or returns a supplied default/block result). |
hash[key] = value |
Store or overwrite the value for a key. |
hash.key?(key) |
Returns true or false for whether a key exists. |
hash.delete(key) |
Removes a key and returns its value (or nil). |
hash.merge(other) |
Returns a new hash combining both, without mutating either. |
hash.each |
Iterates over [key, value] pairs in insertion order. |
hash.map / select / reject |
Transform or filter pairs, returning a new array or hash. |
hash.keys / hash.values |
Returns an array of just the keys, or just the values. |
hash.dig(k1, k2, ...) |
Safely traverses nested hashes/arrays, returning nil instead of raising if a step is missing. |
Examples
Example 1: Creating, reading, and iterating a hash
capitals = {
"France" => "Paris",
"Japan" => "Tokyo",
"Egypt" => "Cairo"
}
puts capitals["Japan"]
puts capitals.fetch("Egypt")
puts capitals["Germany"].inspect
capitals.each do |country, capital|
puts "#{country} -> #{capital}"
end
Output:
Tokyo
Cairo
nil
France -> Paris
Japan -> Tokyo
Egypt -> Cairo
This hash uses string keys with the hash rocket syntax, since "France" and friends aren’t symbols. capitals.fetch("Egypt") behaves like capitals["Egypt"] here, but fetch would raise a KeyError instead of silently returning nil if the key were missing — capitals["Germany"] shows that plain [] returns nil for an absent key instead. Finally, each yields every [key, value] pair in the exact order they were inserted.
Example 2: Keyword arguments, default values, and merge
def build_profile(name:, age:, role: "student")
{ name: name, age: age, role: role }
end
profile = build_profile(name: "Maya", age: 27)
puts "#{profile[:name]} (#{profile[:age]}) - #{profile[:role]}"
counts = Hash.new(0)
%w[apple banana apple cherry banana apple].each do |fruit|
counts[fruit] += 1
end
counts.each { |fruit, total| puts "#{fruit}: #{total}" }
merged = profile.merge(role: "mentor")
puts merged[:role]
puts profile[:role]
Output:
Maya (27) - student
apple: 3
banana: 2
cherry: 1
mentor
student
build_profile takes required keyword arguments (name:, age:) plus one with a default (role: "student"), then returns a hash literal built from them. counts demonstrates Hash.new(0): any fruit that hasn’t been seen yet reads as 0 by default, so counts[fruit] += 1 works without ever calling key? first. Finally, merge returns a brand-new hash with role overridden to "mentor" — profile itself is untouched, which is why profile[:role] still prints "student" afterward.
Example 3: Destructuring a hash with pattern matching
def describe(event)
case event
in { type: "login", user: String => name }
"#{name} logged in"
in { type: "error", code: Integer => code, message: }
"Error #{code}: #{message}"
else
"Unknown event"
end
end
puts describe({ type: "login", user: "Alex" })
puts describe({ type: "error", code: 404, message: "Not Found" })
puts describe({ type: "logout" })
Output:
Alex logged in
Error 404: Not Found
Unknown event
case/in matches the shape of each hash. The first pattern only matches when type is exactly "login" and user is a String, binding it to the local variable name. The second pattern matches type: "error" plus an Integer code (captured with => code) and a message: shorthand that binds the value to a local variable of the same name. A hash with type: "logout" matches neither pattern, so execution falls through to else.
How It Works, Step by Step
Take capitals["Japan"] from Example 1. Step by step, Ruby:
- Calls
"Japan".hashto compute an integer hash code for the key you’re looking up. - Uses that number to jump directly to the internal bucket where a key with that hash code would live.
- Compares the key(s) in that bucket to
"Japan"usingeql?to confirm an exact match (not just an equal-looking one). - Returns the associated value,
"Tokyo", on a match — or the hash’s default value (nil, unless one was configured) if no bucket entry matches.
Iteration works differently: each doesn’t walk buckets in hash order. Instead, Ruby maintains a separate insertion-order record alongside the bucket table, and every iterator walks that record. That’s why capitals.each always yields France, then Japan, then Egypt — the order they were written in the literal — even though their hash codes have nothing to do with that order.
Common Mistakes
Mistake 1: A mutable default value shared across every key
Hash.new(some_object) stores one single object as the default, and every missing-key lookup returns that same object — not a fresh copy. Mutating what you get back mutates the shared default for every other missing key too:
grades = Hash.new([])
grades[:alice] << 90
grades[:bob] << 75
puts grades[:alice].inspect
Output:
[90, 75]
Neither :alice nor :bob was ever actually stored as a key — grades[:alice] just returns the default array again, which now contains both pushes because it’s the very same object every time. The fix is the block form of Hash.new, which runs fresh for each missing key and can assign a brand-new object into the hash:
grades = Hash.new { |hash, key| hash[key] = [] }
grades[:alice] << 90
grades[:bob] << 75
puts grades[:alice].inspect
puts grades[:bob].inspect
Output:
[90]
[75]
Mistake 2: Adding a key to a hash while iterating over it
Ruby actively guards against adding a new key to a hash in the middle of an each loop over that same hash, because the internal iteration bookkeeping can’t safely handle the table growing mid-loop:
scores = { alice: 90, bob: 75 }
scores.each do |name, score|
scores[:carol] = 100 if name == :bob
end
Nothing is printed before this raises RuntimeError: can't add a new key into hash during iteration. The fix is to collect any additions separately and merge them in once the loop is finished:
scores = { alice: 90, bob: 75 }
additions = {}
scores.each do |name, score|
additions[:carol] = 100 if name == :bob
end
scores = scores.merge(additions)
puts scores.keys.join(", ")
Output:
alice, bob, carol
Mistake 3: Assuming JSON.parse gives you symbol keys
JSON.parse returns string keys by default, not symbols, which trips up code written as if it were a normal Ruby hash literal:
require "json"
json_text = '{"name": "Sam", "role": "admin"}'
user = JSON.parse(json_text)
puts user[:name].inspect
puts user["name"]
Output:
nil
Sam
user[:name] looks up the symbol :name, but the hash only has the string key "name", so it silently returns nil. Pass symbolize_names: true to get symbol keys from the start:
require "json"
json_text = '{"name": "Sam", "role": "admin"}'
user = JSON.parse(json_text, symbolize_names: true)
puts user[:name]
puts user[:role]
Output:
Sam
admin
Best Practices
- Prefer symbol keys (
:status) for internal, fixed identifiers; reserve string keys for values that are genuinely arbitrary text. - Use
fetchinstead of[]when a missing key should be a loud error rather than a silentnil— it catches typos immediately. - Use
digto safely read from nested hashes without a chain of manualnilchecks. - Reach for
Hash.new(0)for counting andHash.new { |h, k| h[k] = [] }for grouping, instead of checkingkey?before every update. - Remember that
merge,select,reject, andtransform_valuesreturn new hashes; their!counterparts mutate in place and can returnnilif nothing changed. - Use
case/inpattern matching when a method needs to branch on the shape of a hash, rather than a long chain ofkey?checks.
Practice Exercises
- Write a method
total_price(cart, prices)wherecartis a hash like{ "apple" => 3, "bread" => 2 }mapping item names to quantities, andpricesmaps item names to unit prices. Return the total cost. (Hint:cart.sum { |item, qty| prices[item] * qty }.) - Write a method
word_frequency(text)that splits a sentence into words and returns a hash counting how many times each word appears. (Hint:Hash.new(0)makes the counting loop a one-liner.) - Using
case/in, write a methodarea(shape)that accepts a hash such as{ kind: "circle", radius: 4 }or{ kind: "rectangle", width: 3, height: 5 }and returns the computed area.
Summary
- A
Hashstores unique keys mapped to values and preserves insertion order for iteration. hash[key]returnsnilfor a missing key; usefetchorkey?when you need to tell "missing" apart from "stored as nil".- Symbols make efficient, conventional hash keys for internal identifiers; strings suit genuinely textual, user-facing data.
Hash.new(default)shares one default object across every missing lookup — use the block form when the default needs to be a fresh mutable object per key.- Never add a new key to a hash while iterating over it with
each; Ruby raises aRuntimeError. Collect changes separately and merge them in afterward. case/inpattern matching can destructure hashes directly, which is often clearer than a chain ofif/elsifchecks.
