ZetCode

Ruby Hashes

last modified October 18, 2026

A hash is Ruby's built-in key-value collection, similar to dictionaries in other languages. Keys are looked up quickly and values can be any object. This guide covers creating hashes, accessing and modifying their contents, iterating over them, using common methods, and choosing between symbol and string keys.

Creating Hashes

Hash literals are written with curly braces. Symbol keys use the name: value form, while any other key type uses the hash rocket =>. Hash.new creates an empty hash and can take a default value, which is returned for missing keys, or a default block, which can compute and store a value on first access.

create_hashes.rb
# Hash literal with symbol keys
person = { name: "Alice", age: 30 }
puts person

# Hash literal with string keys (hash rocket)
colors = { "red" => "#ff0000", "green" => "#00ff00" }
puts colors

# Hash.new with a default value
counts = Hash.new(0)
"hello".each_char { |c| counts[c] += 1 }
puts counts

# Hash.new with a default block
lists = Hash.new { |hash, key| hash[key] = [] }
lists[:fruits] << "apple"
lists[:fruits] << "pear"
p lists

# Empty hash and size
empty = {}
puts empty.empty?
puts person.size

Note that puts prints a hash using its to_s form, which in Ruby 3.0 shows the => notation. The default block { |hash, key| hash[key] = [] } stores the new array, so the next access to :fruits returns the same array.

$ ./create_hashes.rb
{:name=>"Alice", :age=>30}
{"red"=>"#ff0000", "green"=>"#00ff00"}
{"h"=>1, "e"=>1, "l"=>2, "o"=>1}
{:fruits=>["apple", "pear"]}
true
2

Accessing Values

Using [] returns nil for a missing key. fetch is stricter: it raises KeyError unless you supply a default value or a block. dig walks nested hashes and arrays, returning nil if any step is missing.

access_hashes.rb
config = { host: "localhost", port: 8080, user: { name: "admin", roles: [:read, :write] } }

puts config[:host]
puts config[:missing].inspect

# fetch with default value
puts config.fetch(:timeout, 30)

# fetch with block
puts config.fetch(:timeout) { |key| "no #{key} set" }

# fetch raises KeyError when key is missing and no default is given
begin
  config.fetch(:timeout)
rescue KeyError => e
  puts "KeyError: #{e.message}"
end

# dig for nested access
puts config.dig(:user, :name)
p config.dig(:user, :roles, 0)
p config.dig(:database, :name)

puts config.key?(:port)

fetch with a default value is useful for optional settings, and the block form lets you compute the fallback lazily. dig returns nil for :database rather than raising, which makes it safe for deeply nested optional data.

$ ./access_hashes.rb
localhost
nil
30
no timeout set
KeyError: key not found: :timeout
admin
:read
nil
true

Adding, Updating, and Deleting Keys

Assigning with []= (or store) adds a new key or updates an existing one. delete removes a key and returns its value; it accepts a block that runs when the key is missing. delete_if removes every pair for which the block returns true.

modify_hashes.rb
scores = { alice: 90, bob: 75 }

# Adding keys
scores[:carol] = 88
scores.store(:dave, 64)
p scores

# Updating a key
scores[:bob] = 80
p scores

# Deleting keys
removed = scores.delete(:dave)
puts "Removed: #{removed}"
p scores

# delete with a block for missing keys
puts scores.delete(:zed) { |k| "#{k} not found" }

# Deleting by condition
scores.delete_if { |_name, score| score < 85 }
p scores

Keys can be added and removed freely at runtime. The block passed to delete handles the missing-key case without raising an error.

$ ./modify_hashes.rb
{:alice=>90, :bob=>75, :carol=>88, :dave=>64}
{:alice=>90, :bob=>80, :carol=>88, :dave=>64}
Removed: 64
{:alice=>90, :bob=>80, :carol=>88}
zed not found
{:alice=>90, :carol=>88}

Iterating Over Hashes

each yields each key and value as two block parameters. each_pair is an alias for each. each_with_index yields a [key, value] pair together with an index, which requires nested destructuring in the block parameters.

iterate_hashes.rb
prices = { apple: 1.2, banana: 0.5, cherry: 3.0 }

# each yields key/value pairs
prices.each do |item, price|
  puts "#{item}: #{price}"
end

# each_pair is an alias of each
prices.each_pair { |item, price| puts "#{item} costs $#{price}" }

# each_with_index gives [key, value] pairs with an index
prices.each_with_index do |(item, price), index|
  puts "#{index + 1}. #{item} - #{price}"
end

# each_key and each_value
prices.each_key { |k| print k, " " }
puts
prices.each_value { |v| print v, " " }
puts

Hashes preserve insertion order, so iteration always visits keys in the order they were added. The pattern |(item, price), index| unpacks the pair while still giving access to the index.

$ ./iterate_hashes.rb
apple: 1.2
banana: 0.5
cherry: 3.0
apple costs $1.2
banana costs $0.5
cherry costs $3.0
1. apple - 1.2
2. banana - 0.5
3. cherry - 3.0
apple banana cherry 
1.2 0.5 3.0 

Common Hash Methods

keys and values return arrays of the respective parts. select returns a new hash of matching pairs, while map returns an array. sort_by returns an array of pairs that can be converted back with to_h. group_by partitions pairs into arrays keyed by the block's result. merge combines hashes, and its block resolves conflicting keys. transform_values and transform_keys build new hashes with converted entries.

common_methods.rb
inventory = { apples: 10, pears: 0, plums: 25, kiwis: 4 }

p inventory.keys
p inventory.values
p inventory.select { |_k, v| v > 5 }
p inventory.map { |k, v| "#{k}=#{v}" }
p inventory.to_a.first(2)
p inventory.sort_by { |_k, v| -v }.to_h
p inventory.group_by { |_k, v| v.zero? ? :empty : :stocked }

a = { x: 1, y: 2 }
b = { y: 10, z: 3 }
p a.merge(b)
p a.merge(b) { |key, old, new| old + new }

p inventory.transform_values { |v| v * 2 }
p inventory.transform_keys(&:to_s)

Plain merge lets the right-hand hash win on conflicts; the block form above sums the duplicate :y values instead. Note that select keeps the hash type, whereas map does not.

$ ./common_methods.rb
[:apples, :pears, :plums, :kiwis]
[10, 0, 25, 4]
{:apples=>10, :plums=>25}
["apples=10", "pears=0", "plums=25", "kiwis=4"]
[[:apples, 10], [:pears, 0]]
{:plums=>25, :apples=>10, :kiwis=>4, :pears=>0}
{:stocked=>[[:apples, 10], [:plums, 25], [:kiwis, 4]], :empty=>[[:pears, 0]]}
{:x=>1, :y=>10, :z=>3}
{:x=>1, :y=>12, :z=>3}
{:apples=>20, :pears=>0, :plums=>50, :kiwis=>8}
{"apples"=>10, "pears"=>0, "plums"=>25, "kiwis"=>4}

Symbol vs String Keys

Symbols are unique, immutable identifiers, and the same symbol object is reused everywhere it appears. Strings with equal content are distinct objects. Because of this, symbol keys are a common choice for fixed option and configuration names, while string keys suit data that comes from external sources. A symbol key and a string key with the same text are different keys.

symbol_string_keys.rb
sym = { name: "Ruby" }
str = { "name" => "Ruby" }

p sym[:name]
p sym["name"]
p str["name"]
p str[:name]

# Symbols are the same object each time; strings are not
p :name.equal?(:name)
p "name".equal?("name")

# Converting between key types
p str.transform_keys(&:to_sym)
p sym.transform_keys(&:to_s)

# Hash with mixed keys
mixed = { 1 => "int", :one => "symbol", "one" => "string" }
p mixed
p mixed.keys.map(&:class)

The lookups show that sym[:name] and str["name"] succeed, while mixing the key types returns nil. Use transform_keys to convert a hash between the two styles.

$ ./symbol_string_keys.rb
"Ruby"
nil
"Ruby"
nil
true
false
{:name=>"Ruby"}
{"name"=>"Ruby"}
{1=>"int", :one=>"symbol", "one"=>"string"}
[Integer, Symbol, String]

Source References

Learn more from these resources: Ruby Hash Documentation, Hash class (Ruby 3.0), and Symbol Documentation.

List all Ruby tutorials.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.