Map (Hash) #
A Hash is a key-value pair data structure that forms the foundation of almost every structured data representation in Ruby — application configuration, HTTP request parameters, database query results, parsed JSON objects, even method options. In Ruby, a Hash is implemented as a hash table providing O(1) access to values by key. What makes Ruby’s Hash special compared to dictionaries in other languages is the flexibility of its keys: keys can be any object — Symbol, String, Integer, even custom objects — although Symbol is the most common and efficient choice. This article covers all aspects of Hash, from creation, safe access, idiomatic transformation, to usage patterns in professional codebases.
Creating Hashes #
There are several ways to create a Hash, each suited to a different situation:
# Modern syntax with symbol keys (most common)
profile = { name: "Rina", age: 28, city: "Bandung" }
# Hash rocket syntax — for non-symbol or mixed keys
config = { "host" => "localhost", "port" => 5432 }
mixed = { :sym => 1, "str" => 2, 42 => "number" }
# Empty hash
empty = {}
empty = Hash.new
# Hash with a default value — the return value when a key doesn't exist
counter = Hash.new(0) # default: 0
groups = Hash.new { |h, k| h[k] = [] } # default: a new empty array
# Building a Hash from an Array
keys = [:name, :age, :city]
values = ["Budi", 30, "Jakarta"]
from_array = keys.zip(values).to_h
puts from_array.inspect
# => {name: "Budi", age: 30, city: "Jakarta"}
# From an array of pairs
from_pairs = [[:a, 1], [:b, 2], [:c, 3]].to_h
puts from_pairs.inspect # => {a: 1, b: 2, c: 3}
# With transform — build a hash from a collection
products = ["laptop", "mouse", "keyboard"]
name_lengths = products.index_by { |p| p }.transform_values(&:length)
# => {"laptop"=>6, "mouse"=>5, "keyboard"=>8}
# index_by — Array into a Hash with keys from the block
people = [
{ id: 1, name: "Rina" },
{ id: 2, name: "Budi" },
{ id: 3, name: "Citra" }
]
by_id = people.index_by { |p| p[:id] }
puts by_id[2].inspect # => {id: 2, name: "Budi"}
Accessing Values #
config = { host: "localhost", port: 5432, database: "app_db", ssl: false }
# Regular access — returns nil if missing (no error)
puts config[:host] # => "localhost"
puts config[:password] # => nil (silently, can hide bugs)
# fetch — raises KeyError if missing (safer for required keys)
puts config.fetch(:host) # => "localhost"
puts config.fetch(:password) # => KeyError: key not found: :password
puts config.fetch(:password, "") # => "" (default if missing)
puts config.fetch(:timeout) { 30 } # => 30 (block as dynamic default)
# values_at — fetch many values at once
host, port, db = config.values_at(:host, :port, :database)
puts "#{host}:#{port}/#{db}" # => localhost:5432/app_db
# slice — take a Hash subset with specific keys (Ruby 2.5+)
connection = config.slice(:host, :port, :database)
puts connection.inspect
# => {host: "localhost", port: 5432, database: "app_db"}
dig — Safe Nested Hash Access #
data = {
user: {
profile: {
name: "Rina",
address: {
city: "Bandung",
postal_code: "40111"
}
}
}
}
# Regular nested access — crashes if any level is nil
puts data[:user][:profile][:address][:city] # => "Bandung"
puts data[:user][:profile][:contact][:phone] # => NoMethodError!
# dig — safe, returns nil if any level is nil
puts data.dig(:user, :profile, :address, :city) # => "Bandung"
puts data.dig(:user, :profile, :contact, :phone) # => nil (no crash)
# Combining dig with safe navigation
city = data.dig(:user, :profile, :address, :city) || "Unknown"
puts city # => "Bandung"
Default Values and Hash.new #
The default value feature is very useful for avoiding the repetitive hash[key] ||= default_value pattern:
# Static default value
counter = Hash.new(0)
["apple", "mango", "apple", "orange", "mango", "apple"].each do |fruit|
counter[fruit] += 1 # no need to check whether the key exists
end
puts counter.inspect
# => {"apple"=>3, "mango"=>2, "orange"=>1}
# ANTI-PATTERN: without a default value — verbose and easy to forget
counter = {}
["apple", "mango", "apple"].each do |fruit|
counter[fruit] ||= 0 # manual initialization every time
counter[fruit] += 1
end
# Default value with a block — a new object per key
groups = Hash.new { |hash, key| hash[key] = [] }
["Rina", "Budi", "Citra", "Deni"].each_with_index do |name, i|
groups[i % 2 == 0 ? :even : :odd] << name
end
puts groups.inspect
# => {even: ["Rina", "Citra"], odd: ["Budi", "Deni"]}
# Automatic nested hashes with a default block
nested = Hash.new { |h, k| h[k] = Hash.new(0) }
nested[:jan][:revenue] += 1_000_000
nested[:jan][:expenses] += 750_000
nested[:feb][:revenue] += 1_200_000
puts nested[:jan].inspect # => {revenue: 1000000, expenses: 750000}
puts nested[:mar].inspect # => {} (created automatically, no crash)
Be careful withHash.new(mutable_object)— every missing key returns the same object. Use the block formHash.new { |h, k| h[k] = [] }to ensure each key gets a separate new object.
Adding, Modifying, and Deleting #
config = { host: "localhost", port: 5432 }
# Add new keys
config[:database] = "app_db"
config[:ssl] = false
# Modify existing values
config[:port] = 5433
config[:host] = "db.production.com"
# Delete a key
deleted_value = config.delete(:ssl) # returns the deleted value
puts deleted_value # => false
puts config.inspect # ssl is gone
# Delete by condition
config = { a: 1, b: nil, c: 3, d: nil, e: 5 }
config.delete_if { |_, v| v.nil? } # delete all entries with nil values
puts config.inspect # => {a: 1, c: 3, e: 5}
# Delete and return a new hash (non-destructive)
clean = config.reject { |_, v| v.nil? }
Merge — Combining Hashes #
merge is one of the most frequently used Hash operations, especially for combining configuration or options:
defaults = { timeout: 30, retries: 3, ssl: false, port: 80 }
override = { timeout: 60, ssl: true }
# merge — returns a new Hash, the original is unchanged
result = defaults.merge(override)
puts result.inspect
# => {timeout: 60, retries: 3, ssl: true, port: 80}
puts defaults.inspect # unchanged
# merge! / update — modifies the original hash
defaults.merge!(override)
puts defaults.inspect # defaults is now updated
# merge with a block — custom key conflict handling
h1 = { a: 1, b: 2, c: 3 }
h2 = { b: 20, c: 30, d: 40 }
result = h1.merge(h2) { |key, old_val, new_val| old_val + new_val }
puts result.inspect
# => {a: 1, b: 22, c: 33, d: 40} (conflicting values summed)
# Merging many hashes at once
a = { x: 1 }
b = { y: 2 }
c = { z: 3 }
combined = [a, b, c].reduce(:merge)
puts combined.inspect # => {x: 1, y: 2, z: 3}
Hash Transformation #
Ruby provides very expressive methods for transforming Hashes without manually building a new Hash:
transform_keys and transform_values #
config = { host: "localhost", port: 5432, database: "app_db" }
# Change all keys — e.g. from symbols to strings
string_keys = config.transform_keys(&:to_s)
puts string_keys.inspect
# => {"host"=>"localhost", "port"=>5432, "database"=>"app_db"}
# Change all keys to uppercase
upper_keys = config.transform_keys { |k| k.to_s.upcase }
puts upper_keys.inspect
# => {"HOST"=>"localhost", "PORT"=>5432, "DATABASE"=>"app_db"}
# Change all values — e.g. converting to strings
string_values = config.transform_values(&:to_s)
puts string_values.inspect
# => {host: "localhost", port: "5432", database: "app_db"}
# Change values with custom logic
prices = { laptop: 15_000_000, mouse: 350_000, keyboard: 450_000 }
after_discount = prices.transform_values { |p| (p * 0.9).round }
puts after_discount.inspect
# => {laptop: 13500000, mouse: 315000, keyboard: 405000}
# Normalizing API data that might be strings or symbols
raw = { "name" => "Rina", "age" => "28", "active" => "true" }
normalized = raw
.transform_keys(&:to_sym)
.transform_values { |v| v == "true" ? true : v == "false" ? false : v }
puts normalized.inspect
# => {name: "Rina", age: "28", active: true}
map and filter on Hash #
scores = { Rina: 85, Budi: 92, Citra: 78, Deni: 60, Eko: 95 }
# select — filter pairs meeting the condition
passed = scores.select { |_, v| v >= 75 }
puts passed.inspect # => {Rina: 85, Budi: 92, Citra: 78, Eko: 95}
# reject — filter pairs NOT meeting the condition
failed = scores.reject { |_, v| v >= 75 }
puts failed.inspect # => {Deni: 60}
# map on a Hash — returns an Array of pairs, needs .to_h
with_grade = scores.map do |name, score|
grade = score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : "D"
[name, "#{score} (#{grade})"]
end.to_h
puts with_grade.inspect
# => {Rina: "85 (B)", Budi: "92 (A)", Citra: "78 (C)", Deni: "60 (D)", Eko: "95 (A)"}
# min_by and max_by — find the pair with the min/max value
puts scores.min_by { |_, v| v }.inspect # => [:Deni, 60]
puts scores.max_by { |_, v| v }.inspect # => [:Eko, 95]
# sort_by — sort by value
scores.sort_by { |_, v| -v }.each { |name, v| puts "#{name}: #{v}" }
# => Eko: 95, Budi: 92, Rina: 85, Citra: 78, Deni: 60
# any? and all? on Hash
puts scores.any? { |_, v| v >= 90 } # => true
puts scores.all? { |_, v| v >= 60 } # => true
puts scores.none? { |_, v| v >= 100 } # => true
Iteration #
profile = { name: "Budi", age: 30, city: "Jakarta", active: true }
# each — iterate over all pairs
profile.each { |k, v| puts "#{k}: #{v}" }
# each_key / each_value — iterate over only keys or values
profile.each_key { |k| print "#{k} " } # => name age city active
profile.each_value { |v| print "#{v} " } # => Budi 30 Jakarta true
# keys and values — as Arrays
puts profile.keys.inspect # => [:name, :age, :city, :active]
puts profile.values.inspect # => ["Budi", 30, "Jakarta", true]
# each_with_object — accumulate iteration results into a specific object
result = profile.each_with_object([]) do |(k, v), arr|
arr << "#{k}=#{v}" if v.is_a?(String)
end
puts result.inspect # => ["name=Budi", "city=Jakarta"]
Nested Hashes #
Nested hashes are very common for representing hierarchical data — multi-level configuration, API responses, or JSON documents:
config = {
database: {
primary: { host: "db1.example.com", port: 5432, pool: 10 },
replica: { host: "db2.example.com", port: 5432, pool: 5 }
},
cache: {
driver: :redis,
host: "cache.example.com",
port: 6379,
ttl: 3_600
},
mailer: {
smtp: { host: "smtp.example.com", port: 587, tls: true },
from: "[email protected]"
}
}
# Access with dig
puts config.dig(:database, :primary, :host) # => "db1.example.com"
puts config.dig(:cache, :ttl) # => 3600
puts config.dig(:database, :sharding, :host) # => nil (safe)
# Nested modification
config[:database][:primary][:pool] = 20
config.dig(:cache)[:ttl] = 7_200
# Deep merge — merge that goes into nested levels
def deep_merge(hash1, hash2)
hash1.merge(hash2) do |_, val1, val2|
if val1.is_a?(Hash) && val2.is_a?(Hash)
deep_merge(val1, val2)
else
val2
end
end
end
defaults = { db: { host: "localhost", port: 5432, pool: 5 } }
overrides = { db: { host: "production.db.com", pool: 20 } }
puts deep_merge(defaults, overrides).inspect
# => {db: {host: "production.db.com", port: 5432, pool: 20}}
Check and Utility Methods #
h = { a: 1, b: 2, c: nil, d: false }
# Key existence checks
puts h.key?(:a) # => true
puts h.key?(:z) # => false
puts h.has_key?(:a) # => true (alias)
puts h.include?(:a) # => true (alias)
puts h.member?(:a) # => true (alias)
# Value checks
puts h.value?(2) # => true
puts h.has_value?(nil) # => true
# Size checks
puts h.size # => 4
puts h.length # => 4 (alias)
puts h.empty? # => false
puts {}.empty? # => true
puts h.any? # => true (there's a truthy element)
puts h.count { |_, v| v } # => 2 (a and b — nil and false aren't truthy)
# invert — swap keys and values
country_codes = { ID: "Indonesia", MY: "Malaysia", SG: "Singapore" }
name_to_code = country_codes.invert
puts name_to_code["Indonesia"] # => :ID
# flatten — flatten a Hash into an Array
puts { a: 1, b: 2 }.flatten.inspect # => [:a, 1, :b, 2]
puts { a: 1, b: 2 }.flatten(2).inspect
# to_a — convert to an array of pairs
puts { a: 1, b: 2 }.to_a.inspect # => [[:a, 1], [:b, 2]]
# assoc — find a pair by key
puts h.assoc(:b).inspect # => [:b, 2]
puts h.assoc(:z).inspect # => nil
# rassoc — find a pair by value
puts country_codes.rassoc("Malaysia").inspect # => [:MY, "Malaysia"]
Hash Usage Patterns in Real Applications #
Hash as Method Options #
# ANTI-PATTERN: many positional parameters — confusing order
def create_user(name, email, age, active, role, city)
# 6 arguments — easy to mix up the order
end
create_user("Rina", "[email protected]", 28, true, :admin, "Bandung")
# CORRECT: keyword arguments (a Hash behind the scenes)
def create_user(name:, email:, age:, active: true, role: :user, city: nil)
{ name: name, email: email, age: age, active: active, role: role, city: city }
end
create_user(name: "Rina", email: "[email protected]", age: 28, role: :admin)
Hash as a Registry #
# Registry pattern — register handlers by key
HANDLERS = {
:json => ->(data) { JSON.parse(data) },
:csv => ->(data) { data.split("\n").map { |r| r.split(",") } },
:yaml => ->(data) { YAML.safe_load(data) }
}.freeze
def parse(data, format)
handler = HANDLERS.fetch(format) do
raise ArgumentError, "Unsupported format: #{format}. Choices: #{HANDLERS.keys.join(', ')}"
end
handler.call(data)
end
Hash as Nested Configuration #
module Config
DEFAULTS = {
server: { host: "0.0.0.0", port: 3000, workers: 4 },
log: { level: :info, format: :json, output: :stdout },
cache: { enabled: true, ttl: 3_600, max_size: 100 }
}.freeze
def self.load(overrides = {})
deep_merge(DEFAULTS, overrides)
end
def self.deep_merge(base, override)
base.merge(override) do |_, v1, v2|
v1.is_a?(Hash) && v2.is_a?(Hash) ? deep_merge(v1, v2) : v2
end
end
end
config = Config.load(
server: { port: 8080, workers: 8 },
log: { level: :debug }
)
puts config.dig(:server, :port) # => 8080 (overridden)
puts config.dig(:server, :host) # => "0.0.0.0" (from defaults)
puts config.dig(:log, :level) # => :debug
puts config.dig(:log, :format) # => :json (from defaults)
Memoization with Hash #
class Fibonacci
def initialize
@memo = { 0 => 0, 1 => 1 }
end
def calculate(n)
@memo[n] ||= calculate(n - 1) + calculate(n - 2)
end
end
fib = Fibonacci.new
puts fib.calculate(50) # => 12586269025 (fast thanks to memoization)
# More elegant memoization with Hash.new
fib_memo = Hash.new { |h, n| h[n] = n < 2 ? n : h[n-1] + h[n-2] }
puts fib_memo[40] # => 102334155
Choosing Between Hash and Struct #
# Hash — for dynamic data or configuration
options = { timeout: 30, retries: 3, verbose: false }
options[:cache] = true # easy to add new keys
# Struct — for more rigid structured data with methods
Point = Struct.new(:x, :y) do
def distance_to_origin
Math.sqrt(x**2 + y**2)
end
end
p = Point.new(3, 4)
puts p.distance_to_origin # => 5.0
puts p == Point.new(3, 4) # => true (comparison by value)
When to use Hash vs Struct vs Class:
Hash:
✓ Configuration and option data that might change
✓ JSON representation or external data
✓ Dynamic keys not known in advance
✓ Data accumulation during iteration
Struct:
✓ Simple data containers with fixed fields
✓ Need automatic == comparison by value
✓ Want helper methods without full boilerplate
Regular class:
✓ There's validation, business logic, or inheritance
✓ Need encapsulation and visibility control
✓ Domain objects with identity (not just data)
Summary #
- Symbol keys are more efficient than String keys —
:nameis a singleton in memory,"name"creates a new object every time it’s written.fetchis safer than[]—fetchraisesKeyErrorif a key is missing, while[]silently returnsnil, which can hide bugs.digfor nested hashes — safer than chaining[][]because it returnsnilinstead of crashing when a level isnil.slice(Ruby 2.5+) for taking a Hash subset with specific keys — more concise thanselect { |k, _| [:a, :b].include?(k) }.Hash.new { |h, k| h[k] = [] }notHash.new([])— the block ensures each key gets a new object, not a shared reference.transform_keysandtransform_valuesfor transforming keys or values without manual loops — idiomatic and expressive.mergewith a block for custom key conflict handling —h1.merge(h2) { |k, v1, v2| v1 + v2 }.index_by(from Enumerable) for converting an Array of objects into a Hash with block-defined keys.- Deep merge needs a manual implementation — built-in
mergeonly goes one level, but a simple recursive pattern handles nested hashes.- Hash is the right choice for method options — use keyword arguments (which are technically a Hash) for methods with many optional parameters.