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 with Hash.new(mutable_object) — every missing key returns the same object. Use the block form Hash.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:name is a singleton in memory, "name" creates a new object every time it’s written.
  • fetch is safer than []fetch raises KeyError if a key is missing, while [] silently returns nil, which can hide bugs.
  • dig for nested hashes — safer than chaining [][] because it returns nil instead of crashing when a level is nil.
  • slice (Ruby 2.5+) for taking a Hash subset with specific keys — more concise than select { |k, _| [:a, :b].include?(k) }.
  • Hash.new { |h, k| h[k] = [] } not Hash.new([]) — the block ensures each key gets a new object, not a shared reference.
  • transform_keys and transform_values for transforming keys or values without manual loops — idiomatic and expressive.
  • merge with 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 merge only 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.

← Previous: List (Array)   Next: Date & Time →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact