Hashes

Published by Programming Line in Ruby. Last updated .

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 returns default_value instead of nil; 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:

  1. Calls "Japan".hash to compute an integer hash code for the key you’re looking up.
  2. Uses that number to jump directly to the internal bucket where a key with that hash code would live.
  3. Compares the key(s) in that bucket to "Japan" using eql? to confirm an exact match (not just an equal-looking one).
  4. 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 fetch instead of [] when a missing key should be a loud error rather than a silent nil — it catches typos immediately.
  • Use dig to safely read from nested hashes without a chain of manual nil checks.
  • Reach for Hash.new(0) for counting and Hash.new { |h, k| h[k] = [] } for grouping, instead of checking key? before every update.
  • Remember that merge, select, reject, and transform_values return new hashes; their ! counterparts mutate in place and can return nil if nothing changed.
  • Use case/in pattern matching when a method needs to branch on the shape of a hash, rather than a long chain of key? checks.

Practice Exercises

  1. Write a method total_price(cart, prices) where cart is a hash like { "apple" => 3, "bread" => 2 } mapping item names to quantities, and prices maps item names to unit prices. Return the total cost. (Hint: cart.sum { |item, qty| prices[item] * qty }.)
  2. 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.)
  3. Using case/in, write a method area(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 Hash stores unique keys mapped to values and preserves insertion order for iteration.
  • hash[key] returns nil for a missing key; use fetch or key? 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 a RuntimeError. Collect changes separately and merge them in afterward.
  • case/in pattern matching can destructure hashes directly, which is often clearer than a chain of if/elsif checks.