Overview
Ruby implements hash access and assignment through several methods that handle key-value pair retrieval and storage. The Hash class provides multiple approaches for accessing values, each with distinct behavior when keys are missing or nil values are stored.
The primary access methods include []
for basic retrieval, fetch
for controlled access with error handling, and dig
for nested structure traversal. Assignment operations use []=
for direct assignment, store
as an alias, and various merge operations for batch updates.
user = { name: "Alice", age: 30, preferences: { theme: "dark" } }
# Basic access patterns
user[:name] # => "Alice"
user[:email] # => nil
user.dig(:preferences, :theme) # => "dark"
Hash access behavior depends on whether keys exist, the configured default value or block, and the specific method used. Missing keys return the default value with []
but raise KeyError
with fetch
unless a fallback is provided.
scores = Hash.new(0) # Default value of 0
scores[:math] # => 0
scores.fetch(:science) # KeyError: key not found: :science
scores.fetch(:science, 85) # => 85
Assignment operations modify the hash structure and return the assigned value. Ruby handles symbol-string key distinctions strictly, treating :name
and "name"
as separate keys unless the hash uses indifferent access patterns.
Basic Usage
The []
operator provides the most common access pattern, returning the value for existing keys or the hash's default value for missing keys. Default values are set during hash creation or through the default=
method.
inventory = { apples: 15, bananas: 8, oranges: 12 }
# Standard access
inventory[:apples] # => 15
inventory[:grapes] # => nil
# With default values
counts = Hash.new(0)
counts[:visitors] += 1 # Creates key with default, then increments
counts[:visitors] # => 1
The []=
operator assigns values to keys, creating new entries or overwriting existing ones. Assignment always returns the assigned value, not the previous value or the hash itself.
config = {}
result = (config[:timeout] = 30) # => 30
config[:retries] = 3
config # => { timeout: 30, retries: 3 }
# Chained assignments
config[:database] = config[:cache] = "redis"
config # => { timeout: 30, retries: 3, database: "redis", cache: "redis" }
The fetch
method provides controlled access with explicit handling of missing keys. Without arguments, fetch
raises KeyError
for missing keys. With a second argument, it returns the fallback value. With a block, it yields the missing key and returns the block result.
settings = { host: "localhost", port: 3000 }
settings.fetch(:host) # => "localhost"
settings.fetch(:password) # KeyError
settings.fetch(:password, "") # => ""
settings.fetch(:timeout) { 60 } # => 60
# Block receives the missing key
settings.fetch(:missing) { |key| "default_#{key}" } # => "default_missing"
The store
method functions identically to []=
but provides a method-style interface that accepts chaining or functional composition patterns.
cache = {}
cache.store(:session_id, "abc123").store(:user_id, 42) # Error: store returns value
# Correct usage:
cache.store(:session_id, "abc123")
cache.store(:user_id, 42)
cache # => { session_id: "abc123", user_id: 42 }
Advanced Usage
The dig
method traverses nested hash structures safely, returning nil if any intermediate key is missing rather than raising an error. This method accepts multiple keys as arguments, drilling down through each level.
data = {
users: {
"123" => { profile: { email: "alice@example.com", preferences: { theme: "dark" } } }
},
settings: { debug: true }
}
data.dig(:users, "123", :profile, :email) # => "alice@example.com"
data.dig(:users, "123", :profile, :preferences, :theme) # => "dark"
data.dig(:users, "456", :profile, :email) # => nil (user doesn't exist)
data.dig(:users, "123", :invalid, :email) # => nil (stops at :invalid)
Hash default values can be set as static values or dynamic blocks. Default blocks receive the hash instance and the missing key, allowing context-aware default generation.
# Static default
word_counts = Hash.new(0)
word_counts[:hello] += 1 # => 1
# Dynamic default with block
auto_arrays = Hash.new { |hash, key| hash[key] = [] }
auto_arrays[:fruits] << "apple"
auto_arrays[:fruits] << "banana"
auto_arrays # => { fruits: ["apple", "banana"] }
# Context-aware defaults
user_sessions = Hash.new do |hash, user_id|
hash[user_id] = {
created_at: Time.now,
requests: 0,
last_activity: Time.now
}
end
session = user_sessions[123] # Creates session automatically
session[:requests] += 1
The values_at
method retrieves multiple values simultaneously, returning an array of values in the same order as the requested keys. Missing keys contribute nil values to the result array.
person = { name: "Bob", age: 25, city: "Portland", country: "USA" }
person.values_at(:name, :age) # => ["Bob", 25]
person.values_at(:name, :missing, :city) # => ["Bob", nil, "Portland"]
# Useful for extracting specific fields
required_fields = person.values_at(:name, :email, :phone)
has_required = required_fields.all? # => false (email and phone are nil)
The fetch_values
method combines values_at
functionality with fetch
behavior, raising KeyError
for missing keys unless blocks or defaults are provided.
config = { host: "db.example.com", port: 5432, ssl: true }
config.fetch_values(:host, :port) # => ["db.example.com", 5432]
config.fetch_values(:host, :password) # KeyError: key not found: :password
# With block for missing keys
config.fetch_values(:host, :password) { |key| "default_#{key}" }
# => ["db.example.com", "default_password"]
Multiple assignment operations can be performed atomically using merge!
or update
, which accept hashes or yield key-value pairs to blocks for conditional updates.
defaults = { timeout: 30, retries: 3, debug: false }
user_config = { timeout: 60, verbose: true }
# Merge overwrites existing keys
final_config = defaults.merge!(user_config)
# => { timeout: 60, retries: 3, debug: false, verbose: true }
# Block-based merging for custom logic
stats = { visits: 10, errors: 2 }
daily_stats = { visits: 5, errors: 1, warnings: 3 }
stats.merge!(daily_stats) { |key, old_val, new_val| old_val + new_val }
# => { visits: 15, errors: 3, warnings: 3 }
Error Handling & Debugging
KeyError
represents the primary exception type for hash access operations, raised by fetch
and fetch_values
when keys are missing and no fallback is provided. The exception message includes the missing key for debugging.
user_data = { id: 1, name: "Alice" }
begin
email = user_data.fetch(:email)
rescue KeyError => e
puts "Missing required field: #{e.key}" # => "Missing required field: email"
puts "Available keys: #{user_data.keys}" # => "Available keys: [:id, :name]"
email = "unknown@example.com"
end
Default value blocks can raise exceptions or perform logging when missing keys are accessed, providing visibility into unexpected access patterns.
monitored_cache = Hash.new do |hash, key|
warn "Cache miss for key: #{key.inspect}"
warn "Available keys: #{hash.keys.inspect}" unless hash.empty?
# Could raise exception for required keys
raise KeyError, "Required cache key missing: #{key}" if key.to_s.start_with?('required_')
nil # Return nil for optional keys
end
monitored_cache[:optional_data] # Logs warning, returns nil
monitored_cache[:required_config] # Raises KeyError
The key?
and has_key?
methods check for key existence without triggering default value generation, preventing unwanted side effects during validation.
lazy_hash = Hash.new { |h, k| h[k] = expensive_computation(k) }
# Wrong: triggers expensive computation
if lazy_hash[:data] # Computes and stores default value
process_data(lazy_hash[:data])
end
# Correct: checks existence without side effects
if lazy_hash.key?(:data) # No computation triggered
process_data(lazy_hash[:data]) # Now triggers computation
end
# Alternative validation patterns
def safe_access(hash, key, &block)
if hash.key?(key)
yield hash[key]
else
puts "Key #{key} not found in hash"
nil
end
end
Nested hash access failures can be debugged by checking each level independently or using custom dig
implementations that provide detailed failure information.
def debug_dig(hash, *keys)
current = hash
path = []
keys.each do |key|
path << key
unless current.respond_to?(:[]) && current.key?(key)
raise KeyError, "Key #{key.inspect} not found at path #{path.inspect}. Available: #{current.keys rescue 'N/A'}"
end
current = current[key]
end
current
end
data = { users: { "1" => { name: "Alice" } } }
debug_dig(data, :users, "2", :name) # Clear error showing where traversal failed
Hash modification during iteration can lead to unexpected behavior or exceptions. Ruby detects some concurrent modifications but not all edge cases.
scores = { alice: 85, bob: 92, charlie: 78 }
# Dangerous: modifying during iteration
scores.each do |name, score|
scores.delete(name) if score < 80 # May skip elements or raise exception
end
# Safe: collect keys first
low_scorers = scores.select { |name, score| score < 80 }.keys
low_scorers.each { |name| scores.delete(name) }
Common Pitfalls
Default values are shared across all missing keys unless default blocks create new objects for each access. This sharing can lead to unintended mutations affecting multiple keys.
# Dangerous: shared default value
groups = Hash.new([])
groups[:admins] << "alice"
groups[:users] << "bob"
groups[:admins] # => ["alice", "bob"] - unexpected!
# Safe: new array for each key
groups = Hash.new { |h, k| h[k] = [] }
groups[:admins] << "alice"
groups[:users] << "bob"
groups[:admins] # => ["alice"]
groups[:users] # => ["bob"]
Symbol and string keys are distinct in hashes, leading to confusion when data comes from mixed sources like JSON parsing or form parameters.
config = { timeout: 30, "retries" => 3 }
config[:timeout] # => 30
config["timeout"] # => nil
config[:retries] # => nil
config["retries"] # => 3
# Solution: consistent key types or indifferent access
config.transform_keys(&:to_s) # All string keys
config.transform_keys(&:to_sym) # All symbol keys
The difference between nil
values and missing keys is lost with the []
operator when default values are nil, but preserved with key?
and fetch
.
data = { active: true, disabled: false, removed: nil }
data[:active] # => true
data[:disabled] # => false
data[:removed] # => nil
data[:missing] # => nil - same as explicit nil!
# Distinguish missing vs nil
data.key?(:removed) # => true
data.key?(:missing) # => false
data.fetch(:removed) # => nil
data.fetch(:missing) # KeyError
Hash assignment with ||=
behaves unexpectedly when values can be falsy but valid, since ||=
assigns when the current value is falsy, not just when the key is missing.
settings = { debug: false, verbose: nil, timeout: 0 }
# Wrong: overwrites valid falsy values
settings[:debug] ||= true # Changes false to true!
settings[:timeout] ||= 30 # Changes 0 to 30!
# Correct: check key existence
settings[:debug] = true unless settings.key?(:debug)
settings[:timeout] = 30 unless settings.key?(:timeout)
# Alternative: use fetch with fallback
settings[:debug] = settings.fetch(:debug, true)
settings[:timeout] = settings.fetch(:timeout, 30)
Deep copying issues arise when hash values contain mutable objects, since assignment and merging operations copy references, not objects.
template = {
config: { retries: 3, timeout: 30 },
users: ["admin"]
}
# Shallow copy shares nested objects
instance1 = template.dup
instance2 = template.dup
instance1[:users] << "alice"
instance2[:users] # => ["admin", "alice"] - unexpected!
instance1[:config][:timeout] = 60
instance2[:config][:timeout] # => 60 - also unexpected!
# Deep copy solution
require 'json'
def deep_copy(obj)
JSON.parse(JSON.dump(obj), symbolize_names: true)
end
instance1 = deep_copy(template)
instance2 = deep_copy(template)
# Now modifications are independent
Hash comparison with ==
checks both keys and values but may not behave as expected when values are arrays or hashes that have been modified in place.
original = { items: [1, 2, 3] }
modified = { items: [1, 2, 3] }
original == modified # => true
original[:items] << 4
original == modified # => false
# But if both reference the same array:
shared_array = [1, 2, 3]
hash1 = { items: shared_array }
hash2 = { items: shared_array }
hash1 == hash2 # => true
hash1[:items] << 4
hash1 == hash2 # => true (still equal because same array object)
Reference
Core Access Methods
Method | Parameters | Returns | Description |
---|---|---|---|
#[](key) |
key (Object) |
Object |
Returns value for key or default value |
#[]=(key, value) |
key (Object), value (Object) |
Object |
Assigns value to key, returns value |
#fetch(key, *args, &block) |
key (Object), optional default, optional block |
Object |
Returns value or raises KeyError |
#store(key, value) |
key (Object), value (Object) |
Object |
Alias for []= |
#dig(*keys) |
*keys (Array of Objects) |
Object or nil |
Traverses nested structure safely |
Batch Access Methods
Method | Parameters | Returns | Description |
---|---|---|---|
#values_at(*keys) |
*keys (Array of Objects) |
Array |
Returns array of values for given keys |
#fetch_values(*keys, &block) |
*keys (Array of Objects), optional block |
Array |
Like values_at but raises KeyError for missing keys |
#slice(*keys) |
*keys (Array of Objects) |
Hash |
Returns new hash with only specified keys |
Existence Check Methods
Method | Parameters | Returns | Description |
---|---|---|---|
#key?(key) |
key (Object) |
Boolean |
Returns true if key exists |
#has_key?(key) |
key (Object) |
Boolean |
Alias for key? |
#include?(key) |
key (Object) |
Boolean |
Alias for key? |
#member?(key) |
key (Object) |
Boolean |
Alias for key? |
Default Value Methods
Method | Parameters | Returns | Description |
---|---|---|---|
#default |
None | Object |
Returns default value |
#default=(obj) |
obj (Object) |
Object |
Sets default value |
#default_proc |
None | Proc or nil |
Returns default proc |
#default_proc=(proc) |
proc (Proc) |
Proc |
Sets default proc |
Modification Methods
Method | Parameters | Returns | Description |
---|---|---|---|
#merge(other, &block) |
other (Hash), optional block |
Hash |
Returns new merged hash |
#merge!(other, &block) |
other (Hash), optional block |
Hash |
Modifies hash in place |
#update(other, &block) |
other (Hash), optional block |
Hash |
Alias for merge! |
#replace(other) |
other (Hash) |
Hash |
Replaces contents with other hash |
Common Hash Creation Patterns
# Empty hash with default value
Hash.new(0)
Hash.new { |h, k| h[k] = [] }
# From arrays
Hash[["a", 1, "b", 2]] # => {"a"=>1, "b"=>2}
Hash[["a", 1], ["b", 2]] # => {"a"=>1, "b"=>2}
# From other enumerables
["a", "b"].zip([1, 2]).to_h # => {"a"=>1, "b"=>2}
{a: 1, b: 2}.invert # => {1=>:a, 2=>:b}
Exception Types
Exception | Raised By | Description |
---|---|---|
KeyError |
fetch , fetch_values |
Missing key without default |
TypeError |
Various | Invalid key type for specialized hashes |
FrozenError |
Modification methods | Attempt to modify frozen hash |
Performance Characteristics
Operation | Time Complexity | Notes |
---|---|---|
#[] , #[]= |
O(1) average | O(n) worst case with hash collisions |
#fetch |
O(1) average | Same as [] but with additional checks |
#dig |
O(k) | Where k is the number of keys |
#merge |
O(n + m) | Where n, m are sizes of both hashes |
#key? |
O(1) average | Faster than accessing value |
Thread Safety Notes
Hash access and assignment operations are not atomic. Concurrent access requires external synchronization for writes, though multiple concurrent reads are safe if no writes occur.
require 'thread'
mutex = Mutex.new
shared_hash = {}
# Safe concurrent access pattern
mutex.synchronize do
shared_hash[:counter] = (shared_hash[:counter] || 0) + 1
end