How to Work with Hashes in Ruby

· 5 min read · Updated March 13, 2026 · beginner
ruby hash hashes data-structures

Hashes are one of Ruby’s most versatile data structures. Whether you’re handling configuration data, mapping keys to values, or building lookup tables, hashes get the job done. This guide covers everything you need to work with hashes in Ruby.

Creating Hashes

Ruby offers several ways to create hashes. The most common is the literal syntax:

person = { name: "Alice", age: 30 }
# => {:name=>"Alice", :age=>30}

You can also use Hash.new with a default value:

counts = Hash.new(0)
counts[:apple]  # => 0 (not nil!)

The Hash[] constructor works well when you have key-value pairs:

Hash["a", 1, "b", 2]
# => {"a"=>1, "b"=>2}

# Or with symbols
Hash[city: "NYC", state: "NY"]
# => {:city=>"NYC", :state=>"NY"}

Accessing Values

The square bracket syntax returns nil for missing keys:

config = { host: "localhost", port: 3000 }
config[:host]    # => "localhost"
config[:missing] # => nil

Use fetch when you need to handle missing keys explicitly:

config.fetch(:host)           # => "localhost"
config.fetch(:missing, "N/A") # => "N/A"

# Raise an error for missing keys
config.fetch(:missing) # raises KeyError

The dig method reaches into nested hashes without raising errors:

data = { user: { address: { city: "Boston" } } }
data.dig(:user, :address, :city)  # => "Boston"
data.dig(:user, :address, :zip)   # => nil (no error!)

Iterating Over Hashes

Ruby gives you several iteration methods:

config = { host: "localhost", port: 3000, ssl: true }

# Iterate over keys
config.each_key { |key| puts key }
# host
# port
# ssl

# Iterate over values
config.each_value { |value| puts value }
# localhost
# 3000
# true

# Iterate over key-value pairs
config.each { |key, value| puts "#{key}: #{value}" }
# host: localhost
# port: 3000
# ssl: true

Merging Hashes

The merge method combines hashes without modifying the original:

defaults = { theme: "dark", lang: "en" }
user_prefs = { theme: "light" }

merged = defaults.merge(user_prefs)
# => {:theme=>"light", :lang=>"en"}
# defaults unchanged

Use merge! (bang method) to modify in place:

options = { debug: false }
options.merge!(timeout: 30)
# => {:debug=>false, :timeout=>30}

For deep merging (recursive), you’ll need a custom approach:

def deep_merge(h1, h2)
  h1.merge(h2) { |key, v1, v2|
    v1.is_a?(Hash) && v2.is_a?(Hash) ? deep_merge(v1, v2) : v2
  }
end

Filtering and Transforming

The select and reject methods filter by condition:

scores = { alice: 95, bob: 72, carol: 88, dave: 55 }

passing = scores.select { |_, score| score >= 70 }
# => {:alice=>95, :bob=>72, :carol=>88}

failing = scores.reject { |_, score| score >= 70 }
# => {:dave=>55}

Transform keys and values with dedicated methods:

data = { name: "Alice", age: 30 }

# Transform keys to uppercase
data.transform_keys(&:upcase)
# => {"NAME"=>"Alice", "AGE"=>30}

# Transform values
data.transform_values(&:to_s)
# => {:name=>"Alice", :age=>"30"}

Common Patterns

Count occurrences

words = %w[apple banana apple cherry banana apple]
counts = Hash.new(0)
words.each { |word| counts[word] += 1 }
# => {"apple"=>3, "banana"=>2, "cherry"=>1}

Reverse a hash

phonebook = { alice: "123", bob: "456" }
reversed = phonebook.invert
# => {"123"=>:alice, "456"=>:bob}

Default values for missing keys

inventory = Hash.new { |hash, key| hash[key] = [] }
inventory[:apples] << "red"
inventory[:apples] << "green"
# => {:apples=>["red", "green"]}

Summary

Hashes in Ruby are flexible and useful. The literal syntax works for most cases, while Hash.new with blocks handles specialized default behavior. Methods like fetch and dig give you safe access patterns, and select, reject, and transform_* methods make filtering and reshaping data straightforward.

Master these patterns and you’ll handle most hash-related tasks with confidence.

Converting Between Formats

Hashes integrate with Ruby’s serialization methods. Convert to arrays when needed:

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

# Keys as array
data.keys    # => [:a, :b, :c]

# Values as array
data.values  # => [1, 2, 3]

# Key-value pairs as nested arrays
data.to_a    # => [[:a, 1], [:b, 2], [:c, 3]]

# Back to hash
Hash[data.to_a]  # => {:a=>1, :b=>2, :c=>3}

Working with JSON is equally straightforward:

require 'json'

# Hash to JSON string
data = { name: "Alice", skills: ["ruby", "rails"] }
json_string = data.to_json
# => "{\"name\":\"Alice\",\"skills\":[\"ruby\",\"rails\"]}"

# JSON string to hash
parsed = JSON.parse(json_string)
# => {"name"=>"Alice", "skills"=>["ruby", "rails"]}

# Symbolize keys during parsing
JSON.parse(json_string, symbolize_names: true)
# => {:name=>:Alice, :skills=>[:ruby, :rails]}

Hash vs Array: When to Use Which

Use hashes when you need key-value associations and fast lookup by key:

# Fast lookup by name
users = { alice: { id: 1, email: "alice@example.com" } }
users[:alice][:email]  # O(1) lookup

Use arrays when you need ordered collections or iteration order matters:

# Ordered collection
queue = [:first, :second, :third]
queue[0]  # => :first

A hash lookup is O(1) on average, while array index lookup is also O(1), but searching an array requires O(n). For large datasets with key-based access, hashes win.

Performance Tips

Creating many hashes? Consider using Hash.new with a block for lazy initialization:

# Instead of
cache = {}
def get_data(key)
  cache[key] ||= expensive_operation(key)
end

# Use
cache = Hash.new { |h, k| h[k] = expensive_operation(k) }

The block form only computes values when they’re actually accessed, not when the hash is created.

See Also

  • Hash#fetch — Safe value access with defaults
  • Hash#any? — Check if any entries match a condition
  • Hash#flat_map — Flatten and transform in one pass
  • JSON — Parse JSON into Ruby hashes
  • YAML — Load YAML config into Ruby hashes