RubyLearning

Helping Ruby Programmers become Awesome!

Ruby Hash: The Complete Guide to Ruby Hashes

By RubyLearning

A Ruby hash is one of the most versatile and frequently used data structures in the language. Hashes store data as key-value pairs, giving you fast lookups by key instead of by numeric index. Whether you are building a Rails application, writing a CLI tool, or processing API responses, understanding Ruby hash methods is essential for writing clean, idiomatic code.

This Ruby hash tutorial covers everything from creating your first hash to advanced techniques like pattern matching and performance optimization. Every example runs on Ruby 3.3+ unless noted otherwise.

Creating Hashes

Ruby gives you several ways to create a hash. The most common is the hash literal syntax using curly braces.

Hash Literal Syntax

# Empty hash
empty = {}

# Hash with string keys (hash rocket syntax)
person = { "name" => "Matz", "language" => "Ruby" }

# Hash with symbol keys (modern syntax, preferred)
person = { name: "Matz", language: "Ruby" }

puts person  # {:name=>"Matz", :language=>"Ruby"}

Hash.new

Hash.new lets you specify a default value returned when accessing a key that does not exist.

# Default value is nil
h = Hash.new
puts h[:missing]  # nil

# Custom default value
counts = Hash.new(0)
counts[:apples] += 1
counts[:apples] += 1
puts counts[:apples]   # 2
puts counts[:oranges]  # 0 (default)

Hash[] and to_h

# Hash[] with alternating key-value arguments
h = Hash["a", 1, "b", 2]
puts h  # {"a"=>1, "b"=>2}

# Hash[] with an array of pairs
h = Hash[ [["x", 10], ["y", 20]] ]
puts h  # {"x"=>10, "y"=>20}

# Converting an array of pairs with to_h
pairs = [[:name, "Ruby"], [:version, "3.3"]]
h = pairs.to_h
puts h  # {:name=>"Ruby", :version=>"3.3"}

# to_h with a block (transformation)
words = ["hello", "world"]
h = words.to_h { |w| [w, w.length] }
puts h  # {"hello"=>5, "world"=>5}

Symbol Keys vs String Keys

In Ruby, symbols are the preferred choice for hash keys because they are immutable and stored in memory only once. String keys are useful when keys come from external sources like JSON or user input.

# Symbol keys (preferred for internal use)
config = { host: "localhost", port: 3000 }

# String keys (common with parsed JSON)
json_data = { "host" => "localhost", "port" => 3000 }

# Important: symbol and string keys are different!
h = { name: "Ruby" }
puts h[:name]    # "Ruby"
puts h["name"]   # nil

Value Omission Shorthand (Ruby 3.1+)

Ruby 3.1 introduced a shorthand syntax where you can omit the value when a local variable has the same name as the key.

# Ruby 3.1+ value omission shorthand
name = "Ruby"
version = "3.3"

# Instead of { name: name, version: version }
lang = { name:, version: }
puts lang  # {:name=>"Ruby", :version=>"3.3"}

Accessing Values

Ruby provides multiple ways to read values from a hash, each suited to different situations.

[] and fetch

person = { name: "Matz", city: "Matsue" }

# Basic access with []
puts person[:name]     # "Matz"
puts person[:age]      # nil (no error)

# fetch raises KeyError if key is missing
puts person.fetch(:name)  # "Matz"
# person.fetch(:age)      # KeyError: key not found: :age

# fetch with a default value
puts person.fetch(:age, "unknown")  # "unknown"

# fetch with a block (lazy default)
puts person.fetch(:age) { |key| "#{key} not set" }  # "age not set"

dig

The dig method safely navigates nested structures, returning nil if any intermediate key is missing.

data = {
  user: {
    address: {
      city: "Tokyo",
      zip: "100-0001"
    }
  }
}

puts data.dig(:user, :address, :city)  # "Tokyo"
puts data.dig(:user, :phone, :mobile)  # nil (no error)

values_at

person = { name: "Matz", city: "Matsue", lang: "Ruby" }

# Retrieve multiple values at once
name, lang = person.values_at(:name, :lang)
puts name  # "Matz"
puts lang  # "Ruby"

Adding and Updating Entries

h = { a: 1, b: 2 }

# Assign a new key
h[:c] = 3

# Update an existing key
h[:a] = 10

# store is an alias for []=
h.store(:d, 4)

puts h  # {:a=>10, :b=>2, :c=>3, :d=>4}

# update (alias for merge!) with a block to resolve conflicts
h.update(a: 100, e: 5) { |key, old, new_val| old + new_val }
puts h  # {:a=>110, :b=>2, :c=>3, :d=>4, :e=>5}

Iterating Over Hashes

each and each_pair

each and each_pair are identical. They yield each key-value pair to the block.

scores = { alice: 95, bob: 87, carol: 92 }

scores.each do |name, score|
  puts "#{name}: #{score}"
end
# alice: 95
# bob: 87
# carol: 92

each_key and each_value

scores = { alice: 95, bob: 87, carol: 92 }

scores.each_key { |k| puts k }
# alice
# bob
# carol

scores.each_value { |v| puts v }
# 95
# 87
# 92

each_with_object

each_with_object is handy for building up a new structure while iterating.

scores = { alice: 95, bob: 87, carol: 92 }

# Build an array of formatted strings
result = scores.each_with_object([]) do |(name, score), arr|
  arr << "#{name} scored #{score}"
end
puts result
# ["alice scored 95", "bob scored 87", "carol scored 92"]

Transforming Hashes

map

Calling map on a hash returns an array. To get a hash back, chain .to_h.

prices = { apple: 1.20, banana: 0.50, cherry: 2.00 }

# map returns an array of arrays
doubled = prices.map { |fruit, price| [fruit, price * 2] }
puts doubled.to_h  # {:apple=>2.4, :banana=>1.0, :cherry=>4.0}

transform_keys and transform_values

These methods return a new hash with transformed keys or values, leaving the original unchanged.

h = { name: "Ruby", version: "3.3" }

# Transform keys to strings
stringified = h.transform_keys(&:to_s)
puts stringified  # {"name"=>"Ruby", "version"=>"3.3"}

# Transform values to uppercase
upped = h.transform_values(&:upcase)
puts upped  # {:name=>"RUBY", :version=>"3.3"}

# Bang versions modify in place
h.transform_values!(&:upcase)
puts h  # {:name=>"RUBY", :version=>"3.3"}

merge and merge!

defaults = { color: "blue", size: "medium", weight: "light" }
overrides = { size: "large", material: "cotton" }

# merge returns a new hash (non-destructive)
result = defaults.merge(overrides)
puts result
# {:color=>"blue", :size=>"large", :weight=>"light", :material=>"cotton"}

# merge with a block to handle conflicts
result = defaults.merge(overrides) { |key, old, new_val| "#{old}/#{new_val}" }
puts result[:size]  # "medium/large"

# merge! (or update) modifies the receiver in place
defaults.merge!(overrides)
puts defaults[:size]  # "large"

Filtering Hashes

select and reject

scores = { alice: 95, bob: 67, carol: 82, dave: 91 }

# select keeps pairs where the block returns true
passing = scores.select { |_name, score| score >= 80 }
puts passing  # {:alice=>95, :carol=>82, :dave=>91}

# reject removes pairs where the block returns true
failing = scores.reject { |_name, score| score >= 80 }
puts failing  # {:bob=>67}

filter_map

filter_map combines filtering and mapping in a single pass. It returns an array, so use .to_h if you need a hash.

scores = { alice: 95, bob: 67, carol: 82 }

# Get only passing students with a grade label
honors = scores.filter_map do |name, score|
  [name, "#{score} (honors)"] if score >= 90
end.to_h
puts honors  # {:alice=>"95 (honors)"}

slice and except (Ruby 3+)

person = { name: "Matz", city: "Matsue", lang: "Ruby", age: 59 }

# slice returns a new hash with only the specified keys
subset = person.slice(:name, :lang)
puts subset  # {:name=>"Matz", :lang=>"Ruby"}

# except returns a new hash without the specified keys (Ruby 3+)
without_age = person.except(:age)
puts without_age  # {:name=>"Matz", :city=>"Matsue", :lang=>"Ruby"}

Querying Hashes

h = { name: "Ruby", version: "3.3" }

# Check for key existence
puts h.key?(:name)       # true
puts h.include?(:name)   # true (alias)
puts h.has_key?(:name)   # true (alias)

# Check for value existence
puts h.value?("3.3")     # true
puts h.has_value?("3.3") # true (alias)

# any? and all?
puts h.any? { |_k, v| v == "Ruby" }   # true
puts h.all? { |_k, v| v.is_a?(String) }  # true

# empty? and size
puts h.empty?  # false
puts h.size    # 2
puts h.length  # 2 (alias for size)

Converting Hashes

h = { a: 1, b: 2, c: 3 }

# to_a converts to an array of pairs
puts h.to_a.inspect  # [[:a, 1], [:b, 2], [:c, 3]]

# invert swaps keys and values
puts h.invert.inspect  # {1=>:a, 2=>:b, 3=>:c}

# flatten turns the hash into a flat array
puts h.flatten.inspect  # [:a, 1, :b, 2, :c, 3]

# keys and values
puts h.keys.inspect    # [:a, :b, :c]
puts h.values.inspect  # [1, 2, 3]

Default Values and Default Procs

A Ruby hash can have a default value or a default proc that is invoked whenever a missing key is accessed. This is one of the most powerful features of the Hash class.

Static Default Value

# Caution: the same object is returned (not a copy)
h = Hash.new([])
h[:fruits] << "apple"
h[:veggies] << "carrot"

# Both keys point to the SAME array!
puts h[:fruits].inspect   # ["apple", "carrot"]
puts h[:veggies].inspect  # ["apple", "carrot"]

Default Proc (Recommended for Mutable Defaults)

# Each missing key gets a NEW empty array
h = Hash.new { |hash, key| hash[key] = [] }
h[:fruits] << "apple"
h[:veggies] << "carrot"

puts h[:fruits].inspect   # ["apple"]
puts h[:veggies].inspect  # ["carrot"]
puts h.inspect
# {:fruits=>["apple"], :veggies=>["carrot"]}

Nested Default Hash (Auto-vivification)

# Create infinitely nested hashes on the fly
nested = Hash.new { |h, k| h[k] = Hash.new(&h.default_proc) }

nested[:a][:b][:c] = "deep value"
puts nested[:a][:b][:c]  # "deep value"
puts nested.inspect
# {:a=>{:b=>{:c=>"deep value"}}}

Nested Hashes and dig

Real-world data is often deeply nested. The dig method is the safe way to traverse nested hashes without raising NoMethodError on nil.

response = {
  data: {
    user: {
      profile: {
        avatar_url: "https://example.com/avatar.png"
      }
    }
  }
}

# Safe navigation with dig
avatar = response.dig(:data, :user, :profile, :avatar_url)
puts avatar  # "https://example.com/avatar.png"

# Returns nil instead of raising an error
missing = response.dig(:data, :user, :settings, :theme)
puts missing  # nil

# Compare with chained [] access:
# response[:data][:user][:settings][:theme]
# => NoMethodError: undefined method `[]' for nil

Hash as Keyword Arguments

Ruby methods can accept keyword arguments, which are closely related to hashes. Understanding this relationship helps you write flexible APIs.

# Method with keyword arguments
def create_user(name:, email:, role: "member")
  puts "#{name} (#{email}) - #{role}"
end

create_user(name: "Alice", email: "alice@example.com")
# Alice (alice@example.com) - member

create_user(name: "Bob", email: "bob@example.com", role: "admin")
# Bob (bob@example.com) - admin

# Double splat (**) converts a hash to keyword arguments
opts = { name: "Carol", email: "carol@example.com", role: "editor" }
create_user(**opts)
# Carol (carol@example.com) - editor

Capturing Extra Keywords with **

def log_event(action:, **metadata)
  puts "Action: #{action}"
  metadata.each { |k, v| puts "  #{k}: #{v}" }
end

log_event(action: "login", ip: "192.168.1.1", browser: "Firefox")
# Action: login
#   ip: 192.168.1.1
#   browser: Firefox

Pattern Matching with Hashes (Ruby 3+)

Ruby 3 introduced powerful pattern matching with the case/in syntax. Hashes work naturally with pattern matching, letting you destructure and match against structure and values simultaneously.

response = { status: 200, body: { users: [{ name: "Alice" }] } }

case response
in { status: 200, body: { users: [{ name: String => first_name }, *] } }
  puts "First user: #{first_name}"
in { status: 404 }
  puts "Not found"
in { status: (500..) }
  puts "Server error"
end
# First user: Alice

Pin Operator and Find Pattern

expected_status = 200

response = { status: 200, data: { items: [1, 2, 3] } }

case response
in { status: ^expected_status, data: { items: [Integer => first, *rest] } }
  puts "Status matched. First item: #{first}, remaining: #{rest}"
end
# Status matched. First item: 1, remaining: [2, 3]

# Pattern matching in conditionals (Ruby 3+)
if response in { status: 200, data: { items: [_, _, _] } }
  puts "Got exactly 3 items with status 200"
end
# Got exactly 3 items with status 200

Performance Considerations

Ruby hashes are implemented as hash tables, giving you O(1) average-case lookup, insertion, and deletion. Here are practical tips to keep your hash operations fast.

  • Prefer symbol keys: Symbols have a precomputed hash value, making them faster as keys than strings. Symbol lookups are roughly 1.5-2x faster in benchmarks.
  • Freeze string keys: Ruby automatically freezes string keys in hash literals since Ruby 2.x. If you build keys dynamically, call .freeze to avoid duplicate allocations.
  • Use fetch over [] + nil check: fetch with a default is clearer and avoids ambiguity between a missing key and a key whose value is nil.
  • Avoid large default procs: A default proc that does heavy computation runs every time a missing key is accessed. Cache results in the hash itself.
  • Use merge sparingly in loops: Each merge creates a new hash. In tight loops, prefer merge! or []= to avoid object churn.
  • Consider compare_by_identity: When you know keys are always the exact same object (common with symbols), compare_by_identity skips eql? and hash calls for even faster lookups.
require "benchmark"

n = 1_000_000
symbol_hash = { name: "Ruby" }
string_hash = { "name" => "Ruby" }

Benchmark.bm(12) do |x|
  x.report("symbol key:") { n.times { symbol_hash[:name] } }
  x.report("string key:") { n.times { string_hash["name"] } }
end
# symbol key:   0.035
# string key:   0.055  (typical results, varies by system)

When building web applications or APIs that handle configuration hashes, choosing the right data structures matters for both speed and readability. Tools like AEO Push demonstrate how modern web development workflows benefit from efficient data handling patterns like the ones covered in this guide.

Quick Reference: Common Ruby Hash Methods

Method Description
[] Access value by key (returns nil if missing)
fetch Access value by key (raises KeyError if missing, or uses default)
dig Safely access nested values
merge / merge! Combine hashes (non-destructive / in-place)
select / reject Filter entries by condition
transform_keys Return new hash with transformed keys
transform_values Return new hash with transformed values
slice Return hash with only specified keys
except Return hash without specified keys (Ruby 3+)
each / each_pair Iterate over key-value pairs
key? / include? Check if key exists
value? / has_value? Check if value exists
to_a Convert to array of [key, value] pairs
invert Swap keys and values

Keep practicing! Hashes are everywhere in Ruby. From Rails params to configuration files, the patterns you learned here apply to nearly every Ruby project.

Continue your learning with the original Ruby Hashes lesson or explore Ruby Arrays for the companion data structure.