Hashes

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them. A Hash is Ruby’s dictionary: an unordered-looking but insertion-ordered collection of key-value pairs with fast lookup by key. It is the second most used core collection after Array, it is what a JSON document becomes when parsed, and it is what keyword arguments are made of. This page covers creating hashes, the order guarantee, reading with [], fetch and dig, defaults and default procs, mutating and iterating safely, transforming and grouping, what makes a good key (eql? and hash), compare_by_identity, the Ruby 3.4 inspect format, keyword-argument conversion, pattern matching, copying and freezing, JSON round trips, and when a Hash should be a Struct, a Data or a class instead. The literal syntax is also covered in Literals; the eql?/hash contract in Equality, Hashing and Comparison; the methods every collection shares in Enumerable; the sibling collections in Arrays, Sets and Struct, Data and Value Objects; how symbols behave as keys in Symbols; keyword arguments in Methods and Parameters and Calling Methods and Argument Forwarding; case/in on hashes in Pattern Matching; and JSON and YAML in Serialization: JSON, YAML, CSV and Marshal. For how other languages spell the same idea, see Go maps and Rust collections.

Every example on this page was syntax-checked with ruby -wc, run on Ruby 4.0.7 and linted with RuboCop 1.92 using the configuration of the running shelf gem (target Ruby 4.0, double-quoted strings). The values in # => comments, and the output in the output blocks, are the real results. Each file is self-contained so it can be run on its own, and the examples reuse the names of the shelf library catalogue used throughout this section (Isbn, Author, Book, Catalog). Where a bare expression statement would draw a "useless use in void context" warning, the example passes it to p, which returns its argument.

What a Hash is

A Hash maps keys to values. Four properties define everything else on this page:

  • Lookup is by key equality, not identity. Two keys are the same key when eql? is true and their hash values are equal. That is why "a" finds "a" even when they are different String objects, and why 1 and 1.0 are different keys.

  • Iteration follows insertion order. This is a documented guarantee (it was not in Ruby 1.8). Re-assigning an existing key keeps its position; deleting a key and adding it again moves it to the end.

  • A missing key yields the hash’s default. By default that is nil; a hash can carry a fixed default value or a default proc that computes one. fetch and key? ignore the default and tell the truth.

  • A Hash is Enumerable. each yields two-element [key, value] arrays, so map, select, sort_by, group_by, sum and the rest work, with the twist that some of them return a Hash and most return an Array of pairs.

Behind the scenes MRI stores small hashes in a compact array and larger ones in a hash table; neither detail is visible except through speed. Lookup, insertion and deletion are amortised constant time for keys with a good hash.

Creating hashes

Literals

A hash literal is a comma-separated list of pairs between braces. Four key forms are in daily use:

  • key: value makes a Symbol key. It is the standard form and the one the 3.4 inspect prints.

  • "text": value makes a Symbol key too (not a String), which allows names that are not valid identifiers, such as "call number": "QA76".

  • expression => value (the "hash rocket") accepts any object as the key: a String, an Integer, nil, an Array. Hashes written with rockets for every key (:title => "Ico") are the pre-1.9 style that RuboCop’s Style/HashSyntax rewrites.

  • x: with no value (Ruby 3.1) is shorthand for x: x: the value is the local variable or method of the same name.

A Hash can be built from another with the double splat: { **defaults, copies: 5 } copies the pairs and a later pair for the same key wins, which is the idiomatic way to apply overrides without mutating anything.

# frozen_string_literal: true

isbn = "9780134685991"
stock = { "9780134685991" => 3, "9781492078005" => 1 }
p stock # => {"9780134685991" => 3, "9781492078005" => 1}

copies = 2
title = "Ico"
book = { title:, isbn:, copies: }
p book # => {title: "Ico", isbn: "9780134685991", copies: 2}

p({ "call number": "QA76", :"x-ray" => true, :+ => 1, if: 1 })
# => {"call number": "QA76", "x-ray": true, "+": 1, if: 1}
p({ 1 => :one, nil => :nothing, [1, 2] => :pair, 1.5 => :float })
# => {1 => :one, nil => :nothing, [1, 2] => :pair, 1.5 => :float}

# rubocop:disable Style/HashConversion
Hash[[%i[a b], [:c, 2]]] # => {a: :b, c: 2}
Hash[:a, 1, :b, 2] # => {a: 1, b: 2}
# rubocop:enable Style/HashConversion
[[:a, 1], [:b, 2]].to_h # => {a: 1, b: 2}
%w[ico bach].to_h { |name| [name.to_sym, name.length] } # => {ico: 3, bach: 4}
%i[title isbn].zip(%w[Ico 9780134685991]).to_h # => {title: "Ico", isbn: "9780134685991"}
Hash(nil) # => {}
Hash.try_convert([[:a, 1]]) # => nil
Hash.try_convert({ a: 1 }) # => {a: 1}
p({ **book, copies: 5 }) # => {title: "Ico", isbn: "9780134685991", copies: 5}
p({ copies: 5, **book }) # => {copies: 2, title: "Ico", isbn: "9780134685991"}

key = +"mutable"
index = { key => 1 }
key << "!"
p index # => {"mutable" => 1}
index.keys.first.frozen? # => true
key.frozen? # => false

Three details from that listing deserve a sentence each. The key that was a String is duplicated and frozen when the pair is stored, so later changes to the original String do not corrupt the hash (index.keys.first.frozen? is true while key itself stays unfrozen); other mutable keys are not copied (see Keys in depth). Duplicate keys in one literal are not an error, the last wins, but ruby -w warns (see Errors and warnings). And a { right after a method name without parentheses is parsed as a block, not a hash, which is why puts { a: 1 } is a syntax error while puts({ a: 1 }) works.

Constructors and conversions

Expression Result Notes

Hash.new, {}

An empty hash

RuboCop prefers the literal {}.

Hash.new(0)

An empty hash whose default value is 0

Positional default; see Defaults and default procs.

Hash.new { |hash, key| ... }

An empty hash with a default proc

The block runs for every missing key.

Hash.new(capacity: n)

An empty hash preallocated for n entries

Ruby 3.4. Saves reallocation when a large hash is built incrementally.

Hash[[[k, v], ...]], Hash[k1, v1, k2, v2]

A hash from pairs or from an even-length list

An odd list raises ArgumentError. RuboCop’s Style/HashConversion prefers to_h or a literal.

array.to_h

A hash from an array of two-element arrays

Raises TypeError for non-array elements and ArgumentError for arrays that are not pairs.

enum.to_h { |x| [k, v] }

A hash from any Enumerable with a block that returns a pair

Since Ruby 2.6. Replaces map { ... }.to_h.

keys.zip(values).to_h

A hash from two parallel lists

Extra keys get nil; extra values are dropped.

Hash(object)

The object converted by Kernel#Hash

Accepts a Hash, nil and [] (both give {}), or an object with to_hash; anything else raises TypeError.

Hash.try_convert(object)

The hash from to_hash, or nil

Returns nil when the object has no to_hash.

The Hash.new default value is evaluated once; the block form is evaluated on each miss. That difference is the single most common source of Hash bugs and is the subject of its own section below.

Insertion order

Ruby 1.9 made the iteration order of a Hash the order of insertion, and that behaviour is part of the language contract. The rules in practice:

  • A new key goes to the end.

  • Assigning to an existing key replaces the value and keeps the position.

  • delete removes the key, so adding it again places it last.

  • shift removes and returns the first pair (nil for an empty hash since Ruby 3.2; earlier versions returned the default).

  • first, first(n) and min_by all see that order. Equality ignores it: two hashes with the same pairs in different order are == and eql?.

  • Sorting never reorders a Hash in place: sort, sort_by and min_by return Arrays of pairs; use .to_h to get a Hash in the new order.

# frozen_string_literal: true

shelf = { a: 1, b: 2, c: 3 }
shelf[:a] = 10 # => 10
shelf.keys # => [:a, :b, :c]
shelf.delete(:a) # => 10
shelf[:a] = 1
shelf.keys # => [:b, :c, :a]
p(shelf == { c: 3, a: 1, b: 2 }) # => true
shelf.eql?({ c: 3, a: 1, b: 2 }) # => true
shelf.to_a # => [[:b, 2], [:c, 3], [:a, 1]]
shelf.first # => [:b, 2]
shelf.first(2) # => [[:b, 2], [:c, 3]]
shelf.min_by { |_, v| v } # => [:a, 1]
shelf.shift # => [:b, 2]
p shelf # => {c: 3, a: 1}
{}.shift # => nil
{ "a" => 1, a: 2 }.length # => 2

Reading values

Reading with brackets, fetch and friends

hash[key] returns the stored value or the default (nil unless configured otherwise). The ambiguity is the weakness: a nil result may mean "absent" or "stored nil`". `fetch removes the ambiguity: it raises KeyError for a missing key, or returns the fallback you supply, as a second argument or as a block. The block is only evaluated when needed and receives the key, so it is the right place for an expensive or side-effecting fallback; supplying both a fallback argument and a block is accepted but warns that the block supersedes it.

The KeyError carries key and receiver, and did_you_mean adds a corrections list, which makes typos in option names easy to spot in a log.

# frozen_string_literal: true

stock = { "9780134685991" => 3, "9781492078005" => 1 }
stock["9780134685991"] # => 3
stock["0000000000000"] # => nil
stock.fetch("9780134685991") # => 3
stock.fetch("0000000000000", 0) # => 0
stock.fetch("0000000000000") { |isbn| "no stock for #{isbn}" } # => "no stock for 0000000000000"
stock.fetch_values("9780134685991", "9781492078005") # => [3, 1]
stock.values_at("9780134685991", "0000000000000") # => [3, nil]
stock.fetch_values("0000000000000", &:to_i) # => [0]

begin
  stock.fetch("0000000000000")
rescue KeyError => e
  e.message # => "key not found: \"0000000000000\""
  e.key # => "0000000000000"
  e.receiver.equal?(stock) # => true
end

begin
  { title: "Ico" }.fetch(:titel)
rescue KeyError => e
  e.message # => "key not found: :titel"
  e.corrections # => [:title]
end

stock.key?("9780134685991") # => true
stock.member?("x") # => false
stock.value?(1) # => true
stock.key(1) # => "9781492078005"
stock.assoc("9781492078005") # => ["9781492078005", 1]
stock.rassoc(3) # => ["9780134685991", 3]
stock.length # => 2
stock.empty? # => false
stock.any? { |_isbn, copies| copies > 2 } # => true
stock.count { |_isbn, copies| copies.odd? } # => 2
stock.sum { |_isbn, copies| copies } # => 4
stock.find { |_isbn, copies| copies == 1 } # => ["9781492078005", 1]
stock.to_proc.call("9780134685991") # => 3
%w[9781492078005 9780134685991].map(&stock) # => [1, 3]
Method Missing key Notes

hash[key]

Default value or nil

Triggers the default proc. dig and values_at call it too.

fetch(key)

KeyError

Ignores the hash’s default completely.

fetch(key, fallback), fetch(key) { |k| ... }

The fallback

Both given: the block wins and Ruby warns.

values_at(*keys)

One entry per key, using the default

Always returns an Array of the same length.

fetch_values(*keys)

KeyError on the first missing key

A block supplies a fallback per missing key.

dig(k1, k2, ...)

nil

Walks nested structures; see below.

key?(k), has_key?, include?, member?

false

All four are aliases; they never use the default.

value?(v), has_value?

false

Linear scan, compares with ==.

key(v)

nil

The first key whose value is == to v; linear.

assoc(k), rassoc(v)

nil

Return the [key, value] pair; rassoc scans values.

slice(*keys)

Missing keys are omitted

Returns a new Hash with only the keys present.

A Hash also converts to a callable: hash.to_proc returns a lambda that looks keys up, so ids.map(&stock) reads the stock of each id.

Nested data with dig

dig follows a chain of keys or indexes through nested hashes, arrays and structs, and returns nil as soon as a step is nil instead of raising. The rules from the documentation:

  • Each intermediate step is looked up with [] (so defaults and default procs apply) and the walk stops with nil if a value is nil.

  • The intermediate object must itself respond to dig: Hash, Array and Struct do. Anything else (a String, an Integer) raises TypeError with the message Integer does not have #dig method. Data has no dig.

  • dig with one argument is just []; RuboCop’s Style/SingleArgumentDig flags it.

Where the structure is required and its absence is a bug, prefer chained fetch calls, which fail loudly at the exact missing key; use dig where absence is normal, for example when reading optional parts of a parsed JSON document.

# frozen_string_literal: true

loan = { book: { title: "Ico", author: { name: "Ueda" } }, due: nil }
loan.dig(:book, :author, :name) # => "Ueda"
loan.dig(:book, :publisher, :name) # => nil
loan.dig(:due, :year) # => nil
loan[:book][:publisher] # => nil
loan.fetch(:book).fetch(:author).fetch(:name) # => "Ueda"

begin
  loan[:book][:publisher][:name]
rescue NoMethodError => e
  e.message # => "undefined method '[]' for nil"
end

begin
  loan.dig(:book, :title, :first)
rescue TypeError => e
  e.message # => "String does not have #dig method"
end

shelves = { rows: [{ slots: %w[Ico Bach] }, { slots: [] }] }
shelves.dig(:rows, 0, :slots, 1) # => "Bach"
shelves.dig(:rows, 1, :slots, 0) # => nil
shelves.dig(:rows, 5, :slots) # => nil
Hash.new { |hash, key| hash[key] = [key] }.dig(:x, 0) # => :x

Defaults and default procs

A hash returns its default for a missing key. There are two kinds, set at construction or later:

  • A default value (Hash.new(0), hash.default = 0) is returned as is and is not stored. The same object is returned every time, which is a trap for mutable values (below).

  • A default proc (Hash.new { |hash, key| ... }, hash.default_proc = proc) is called with the hash and the key. It may compute and return a value, and it may also store it (hash[key] = ...), which is how self-filling hashes work.

Rules worth remembering:

  • [] consults the default, and so do dig and values_at, which call it. fetch, fetch_values, key?, slice, to_a, size and each do not. A default value never creates an entry, and a default proc creates one only if it assigns.

  • Giving both a positional default and a block raises ArgumentError. Assigning default= clears a default proc and default_proc= clears the default value. The default_proc= argument must accept two arguments (a lambda that takes one raises TypeError).

  • Copies made with dup, clone, merge, compact and to_h keep the default; select, reject, slice, except, transform_values and to_h with a block build a plain hash without it.

  • Hash.new(0) is the standard counter; Hash.new { |h, k| h[k] = [] } the standard grouper; the recursive form Hash.new { |h, k| h[k] = Hash.new(&h.default_proc) } the standard autovivifying tree.

The shared-default pitfall. Hash.new([]) creates one Array and returns that same object for every missing key. Appending to it changes the default for all keys and stores nothing, so the hash stays empty and hash[:anything] suddenly returns the collected elements. RuboCop reports it as Lint/SharedMutableDefault. The listing keeps it on purpose, with a cop directive, to show the symptom. The block form allocates a fresh Array per key and stores it.

# frozen_string_literal: true

visits = Hash.new(0)
%w[ico bach ico].each { |title| visits[title] += 1 }
p visits # => {"ico" => 2, "bach" => 1}
visits["missing"] # => 0
visits.key?("missing") # => false
visits.fetch("missing", :none) # => :none
visits.default # => 0
visits.default = 5
visits["still-missing"] # => 5
visits.size # => 2

shared = Hash.new([]) # rubocop:disable Lint/SharedMutableDefault
shared[:a] << "Ico"
shared[:b] << "Bach"
p shared # => {}
shared[:zzz] # => ["Ico", "Bach"]
shared.default # => ["Ico", "Bach"]

by_author = Hash.new { |hash, author| hash[author] = [] }
by_author["Ueda"] << "Ico"
by_author["Ueda"] << "Shadow of the Colossus"
by_author["Bach"] << "Cello Suites"
p by_author # => {"Ueda" => ["Ico", "Shadow of the Colossus"], "Bach" => ["Cello Suites"]}
by_author.default_proc.class # => Proc
by_author.default_proc.lambda? # => false

lazy = Hash.new { |_hash, key| key.to_s * 2 }
lazy[:ab] # => "abab"
lazy.size # => 0
lazy.fetch(:ab, nil) # => nil

tree = Hash.new { |hash, key| hash[key] = Hash.new(&hash.default_proc) }
tree[:a][:b][:c] = 1
p tree # => {a: {b: {c: 1}}}

lookup = { a: 1 }
lookup.default_proc = ->(_hash, key) { "no #{key}" }
lookup[:z] # => "no z"
lookup.default # => nil

Hash.new(capacity: 1000).default # => nil

begin
  Hash.new(0) { |_h, _k| 1 }
rescue ArgumentError => e
  e.message # => "wrong number of arguments (given 1, expected 0)"
end

begin
  Hash.new(&->(_a) { 1 })
rescue TypeError => e
  e.message # => "default_proc takes two arguments (2 for 1)"
end

Modifying a hash

Method What it does Returns

hash[key] = value, store(key, value)

Adds or replaces

The value

delete(key), delete(key) { |k| ... }

Removes the pair; the block runs when the key is absent

The removed value, or the block’s result, or nil

delete_if, reject!

Removes pairs for which the block is true

The hash (delete_if) or nil when nothing changed (reject!)

keep_if, select!, filter!

Keeps only pairs for which the block is true

The hash (keep_if) or nil when nothing changed (select!)

compact!

Removes nil values

The hash, or nil when nothing changed

merge(*others), merge(*others) { |key, old, new| ... }

A new hash with the other hashes applied left to right; the block resolves conflicts

A new hash; with no arguments, a copy

merge!, update

The same, in place

The receiver

transform_values!, transform_keys!

Rewrites values or keys in place

The receiver

replace(other)

Makes the receiver a copy of other, default included

The receiver

clear, shift

Empties the hash; removes and returns the first pair

The hash; the pair or nil

Two conventions help when reading code. The bang methods reject!, select! and compact! return nil when they changed nothing, so do not chain on their result; delete_if and keep_if always return the hash. And merge with a block is the tool for combining counts or nested settings, because the block is called only for keys present on both sides.

Modifying during iteration. Deleting or reassigning existing keys while iterating is allowed. Adding a new key raises RuntimeError ("can’t add a new key into hash during iteration"), because the table might be resized under the iterator. Collect the changes first and apply them after the loop, or iterate over a copy (hash.dup.each).

Frozen hashes. freeze makes the hash itself read-only: every mutator raises FrozenError, but the values stay as mutable as they were, and merge still returns a new unfrozen hash. Ractor.make_shareable freezes a structure deeply; see Copying, freezing and sharing.

# frozen_string_literal: true

stock = { "9780134685991" => 3, "9781492078005" => 1 }
stock["9780134685991"] += 1 # => 4
stock.store("9780596516177", 2) # => 2
stock.delete("9781492078005") # => 1
stock.delete("nope") # => nil
stock.delete("nope") { |key| "#{key} was absent" } # => "nope was absent"
stock.merge("9780134685991" => 10) # => {"9780134685991" => 10, "9780596516177" => 2}
stock.merge({ "9780134685991" => 10 }) { |_k, old, new| old + new } # => {"9780134685991" => 14, "9780596516177" => 2}
stock.merge({ "a" => 1 }, { "b" => 2 }) # => {"9780134685991" => 4, "9780596516177" => 2, "a" => 1, "b" => 2}
stock.merge # => {"9780134685991" => 4, "9780596516177" => 2}
stock.update("9780596516177" => 0) # => {"9780134685991" => 4, "9780596516177" => 0}
stock.reject! { |_isbn, copies| copies.zero? } # => {"9780134685991" => 4}
stock.reject! { |_isbn, copies| copies.zero? } # => nil
stock.select! { |_isbn, copies| copies.positive? } # => nil
stock.compact! # => nil
{ a: 1, b: nil }.compact # => {a: 1}
stock.delete_if { |_isbn, copies| copies > 100 } # => {"9780134685991" => 4}
stock.keep_if { |_isbn, copies| copies > 100 } # => {}
stock.replace({ "k" => 1 }) # => {"k" => 1}
stock.clear # => {}

counts = { a: 1, b: 2 }
counts.each_key { |key| counts.delete(key) if key == :a }
p counts # => {b: 2}

begin
  counts.each_key { |key| counts[:"#{key}2"] = 0 }
rescue RuntimeError => e
  e.class # => RuntimeError
  e.message # => "can't add a new key into hash during iteration"
end

counts.each_key { |key| counts[key] = 99 }
p counts # => {b: 99}

frozen = { a: 1 }.freeze
begin
  frozen[:b] = 2
rescue FrozenError => e
  e.message # => "can't modify frozen Hash: {a: 1}"
end
frozen.merge(b: 2) # => {a: 1, b: 2}
frozen.frozen? # => true
frozen.merge(b: 2).frozen? # => false
{ a: { b: 1 } }.freeze[:a].frozen? # => false

Iterating

each and each_pair are the same method. They yield one argument, a two-element array [key, value], which blocks with two parameters destructure automatically. Everything Enumerable offers then applies to the pairs: map, flat_map, filter_map, sum, count, find, partition, min_by/max_by, sort_by, each_with_object, reduce (whose block destructures with |sum, (key, value)|), each_slice, zip, lazy.

Rules and traps:

  • each_key and each_value iterate one side; without a block they return Enumerators (each_value.sum).

  • The Hash-returning methods are select/filter, reject, slice, except, merge, compact, transform_values, transform_keys, invert and to_h. Nearly everything else (map, sort_by, partition, min_by, group_by values, zip, flatten, sort) returns an Array. Add .to_h to get a Hash back.

  • A numbered parameter or it receives the whole pair when used alone: hash.map { it } yields [key, value] arrays; _1 and _2 destructure it. it (Ruby 3.4) is a good fit when the pair is passed through; for two named parts prefer |key, value|. RuboCop’s Style/NumberedParametersLimit forbids using more than one numbered parameter.

  • A lambda passed with & does not destructure: since Ruby 3.0 Hash#each and map always yield a single two-element Array, so a lambda taking two parameters raises ArgumentError. A proc (or a block) destructures. Write ->(pair) { ... } or use a block.

  • The order is the insertion order, so first, each_slice and take_while are deterministic.

  • Unused block parameters are conventionally prefixed with _ (|_title, copies|), which RuboCop’s Lint/UnusedBlockArgument expects.

# frozen_string_literal: true

stock = { "Ico" => 3, "Bach" => 0, "Shadow" => 1 }

stock.each { |title, copies| puts "#{title}: #{copies}" }
stock.each_pair { |pair| p pair }
stock.each_with_index { |(title, copies), index| puts "#{index} #{title} #{copies}" }
stock.each.next # => ["Ico", 3]
stock.each_key.to_a # => ["Ico", "Bach", "Shadow"]
stock.each_value.sum # => 4
stock.map { |title, copies| "#{title}x#{copies}" } # => ["Icox3", "Bachx0", "Shadowx1"]
stock.map { it } # => [["Ico", 3], ["Bach", 0], ["Shadow", 1]]
stock.map { _1 } # => [["Ico", 3], ["Bach", 0], ["Shadow", 1]]
stock.filter_map { |title, copies| title if copies.positive? } # => ["Ico", "Shadow"]
stock.flat_map { |title, copies| [title] * copies } # => ["Ico", "Ico", "Ico", "Shadow"]
stock.sum { |_title, copies| copies } # => 4
stock.sort_by { |_title, copies| -copies } # => [["Ico", 3], ["Shadow", 1], ["Bach", 0]]
stock.sort_by { |_title, copies| -copies }.to_h # => {"Ico" => 3, "Shadow" => 1, "Bach" => 0}
stock.max_by { |_title, copies| copies } # => ["Ico", 3]
stock.partition { |_title, copies| copies.zero? } # => [[["Bach", 0]], [["Ico", 3], ["Shadow", 1]]]
stock.each_with_object({}) { |(title, copies), acc| acc[copies] = title } # => {3 => "Ico", 0 => "Bach", 1 => "Shadow"}
stock.reduce(0) { |sum, (_title, copies)| sum + copies } # => 4
stock.zip([1, 2, 3]) # => [[["Ico", 3], 1], [["Bach", 0], 2], [["Shadow", 1], 3]]
stock.min_by(2) { |_title, copies| copies } # => [["Bach", 0], ["Shadow", 1]]
stock.each_slice(2).to_a # => [[["Ico", 3], ["Bach", 0]], [["Shadow", 1]]]
stock.sort # => [["Bach", 0], ["Ico", 3], ["Shadow", 1]]
stock.to_a.transpose # => [["Ico", "Bach", "Shadow"], [3, 0, 1]]
stock.flatten # => ["Ico", 3, "Bach", 0, "Shadow", 1]
{ a: [1, [2]] }.flatten(2) # => [:a, 1, [2]]
stock.all? { |_title, copies| copies >= 0 } # => true
stock.any?(["Ico", 3]) # => true
stock.include?("Ico") # => true
stock.lazy.select { |_t, c| c.positive? }.map(&:first).first(1) # => ["Ico"]

Output:

Ico: 3
Bach: 0
Shadow: 1
["Ico", 3]
["Bach", 0]
["Shadow", 1]
0 Ico 3
1 Bach 0
2 Shadow 1
# frozen_string_literal: true

stock = { "Ico" => 3, "Bach" => 0 }

two = ->(title, copies) { "#{title}:#{copies}" }
one = ->(pair) { pair.join(":") }

begin
  stock.map(&two)
rescue ArgumentError => e
  e.message # => "wrong number of arguments (given 1, expected 2)"
end

stock.map(&one) # => ["Ico:3", "Bach:0"]
stock.map { |title, copies| two.call(title, copies) } # => ["Ico:3", "Bach:0"]
stock.map(&proc { |title, copies| "#{title}:#{copies}" }) # => ["Ico:3", "Bach:0"]
stock.each_with_index.map { |(title, copies), index| "#{index}#{title}#{copies}" } # => ["0Ico3", "1Bach0"]
stock.any? { |title, copies| title.size == copies } # => true
stock.sum { _2 } # => 3

Transforming, filtering and grouping

The transformation methods return new hashes (and have bang forms that change the receiver). The ones to know:

  • transform_values (Ruby 2.4) maps values and keeps keys: the right tool for "apply this to every value".

  • transform_keys (2.5) maps keys. Since Ruby 3.0 it also takes a hash of old-to-new keys, optionally together with a block for keys not listed. If two keys map to the same new key the later pair overwrites the earlier.

  • to_h { |key, value| [new_key, new_value] } (2.6) rebuilds a hash from pairs, changing both sides.

  • select/filter, reject, compact filter pairs and return hashes; filter_map (2.7) maps and filters in one pass but returns an Array.

  • slice(*keys) (2.5) keeps the listed keys; except(*keys) (3.0) drops them. Both ignore keys that are absent.

  • invert swaps keys and values; duplicate values collapse (the last key wins).

  • group_by returns a Hash of Arrays, grouped by the block’s result; partition returns two Arrays; tally (2.7) counts equal elements into a Hash, and since 3.1 takes a hash to add to. each_with_object builds any shape.

  • sum, min_by, max_by and sort_by take blocks that destructure the pair, as in the iteration section.

# frozen_string_literal: true

stock = { "Ico" => 3, "Bach" => 0, "Shadow" => 1 }

stock.transform_values { it * 2 } # => {"Ico" => 6, "Bach" => 0, "Shadow" => 2}
stock.transform_values(&:to_s) # => {"Ico" => "3", "Bach" => "0", "Shadow" => "1"}
stock.transform_keys(&:to_sym) # => {Ico: 3, Bach: 0, Shadow: 1}
stock.transform_keys(&:upcase) # => {"ICO" => 3, "BACH" => 0, "SHADOW" => 1}
stock.transform_keys("Ico" => "ICO") # => {"ICO" => 3, "Bach" => 0, "Shadow" => 1}
stock.transform_keys({ "Ico" => "ICO" }, &:downcase) # => {"ICO" => 3, "bach" => 0, "shadow" => 1}
stock.transform_keys { :same } # => {same: 1}
copy = stock.dup
copy.transform_values!(&:succ) # => {"Ico" => 4, "Bach" => 1, "Shadow" => 2}
copy.transform_keys!(&:downcase) # => {"ico" => 4, "bach" => 1, "shadow" => 2}

stock.to_h { |title, copies| [title.downcase.to_sym, copies] } # => {ico: 3, bach: 0, shadow: 1}
stock.select { |_title, copies| copies.positive? } # => {"Ico" => 3, "Shadow" => 1}
stock.filter { |title, _copies| title.start_with?("S") } # => {"Shadow" => 1}
stock.reject { |_title, copies| copies.zero? } # => {"Ico" => 3, "Shadow" => 1}
stock.slice("Ico", "missing") # => {"Ico" => 3}
stock.except("Ico", "missing") # => {"Bach" => 0, "Shadow" => 1}
stock.invert # => {3 => "Ico", 0 => "Bach", 1 => "Shadow"}
{ a: 1, b: 1 }.invert # => {1 => :b}
stock.min_by { |_title, copies| copies } # => ["Bach", 0]
stock.sum { |_title, copies| copies } # => 4
stock.group_by { |_title, copies| copies.zero? ? :out : :in }
# => {in: [["Ico", 3], ["Shadow", 1]], out: [["Bach", 0]]}
stock.group_by { |_title, copies| copies.zero? ? :out : :in }.transform_values(&:to_h)
# => {in: {"Ico" => 3, "Shadow" => 1}, out: {"Bach" => 0}}
stock.each_with_object(Hash.new { |h, k| h[k] = [] }) { |(title, n), acc| acc[n.zero?] << title }
# => {false => ["Ico", "Shadow"], true => ["Bach"]}
%w[ico bach ico shadow ico].tally # => {"ico" => 3, "bach" => 1, "shadow" => 1}
%w[ico bach ico].map(&:upcase).tally # => {"ICO" => 2, "BACH" => 1}
%w[ico bach ico].tally({ "ico" => 10 }) # => {"ico" => 12, "bach" => 1}
%w[ico bach shadow].group_by(&:size) # => {3 => ["ico"], 4 => ["bach"], 6 => ["shadow"]}
%w[ico bach shadow].to_h { [it.to_sym, it.size] } # => {ico: 3, bach: 4, shadow: 6}
%w[ico bach].each_with_index.to_h # => {"ico" => 0, "bach" => 1}
%w[ico bach].zip([1, 2]).to_h # => {"ico" => 1, "bach" => 2}

A hash-based catalogue

The shelf library catalogue uses hashes for its indexes: books by ISBN, titles grouped by author, counts per author, and cached lookups. The same file shows the memoisation idiom (a default proc that stores its result) and a recursive deep merge for layered settings, which a plain merge does not do because it replaces a nested hash as a whole.

# frozen_string_literal: true

module Shelf
  Author = Data.define(:name)
  Book = Data.define(:isbn, :title, :author)
end

ueda = Shelf::Author.new("Ueda")
bach = Shelf::Author.new("Bach")
books = [
  Shelf::Book.new("9780134685991", "Ico", ueda),
  Shelf::Book.new("9781492078005", "Shadow of the Colossus", ueda),
  Shelf::Book.new("9780596516177", "Cello Suites", bach)
]

by_isbn = books.to_h { [it.isbn, it] }
by_isbn.fetch("9780596516177").title # => "Cello Suites"

by_author = books.group_by(&:author)
by_author.transform_values { |list| list.map(&:title) }
# => {#<data Shelf::Author name="Ueda"> => ["Ico", "Shadow of the Colossus"], #<data Shelf::Author name="Bach"> => ["Cello Suites"]}

counts = books.map { it.author.name }.tally
p counts # => {"Ueda" => 2, "Bach" => 1}
counts.max_by(&:last) # => ["Ueda", 2]
counts.min_by { |name, n| [-n, name] } # => ["Ueda", 2]
counts.sum { _2 } # => 3

titles_by_initial = books.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |book, acc|
  acc[book.title[0]] << book.title
end
p titles_by_initial # => {"I" => ["Ico"], "S" => ["Shadow of the Colossus"], "C" => ["Cello Suites"]}

# Memoise an expensive lookup with a self-filling Hash.
lookups = 0
slug = Hash.new do |cache, title|
  lookups += 1
  cache[title] = title.downcase.tr(" ", "-")
end
slug["Shadow of the Colossus"] # => "shadow-of-the-colossus"
slug["Shadow of the Colossus"] # => "shadow-of-the-colossus"
p lookups # => 1

# Merge nested settings recursively.
def deep_merge(base, extra)
  base.merge(extra) do |_key, old, new|
    old.is_a?(Hash) && new.is_a?(Hash) ? deep_merge(old, new) : new
  end
end

defaults = { loan: { days: 14, renew: true }, store: :memory }
overrides = { loan: { days: 7 }, store: :disk }
deep_merge(defaults, overrides) # => {loan: {days: 7, renew: true}, store: :disk}

Keys in depth

The eql? and hash contract

A Hash finds a key by calling hash on it to choose a bucket and eql? to confirm a match. Two objects are the same key when a.eql?(b) is true, which requires a.hash == b.hash. The consequences:

  • eql? is stricter than ==: 1 == 1.0 is true but 1.eql?(1.0) is false and their hashes differ, so { 1 => :int }[1.0] is nil. The same holds for 1 and Rational(1).

  • Strings, Symbols, Integers, Floats, Arrays, Hashes, Ranges, Struct and Data instances compare and hash by content, so they work as keys. A plain Object hashes by identity.

  • A class that overrides == but not eql? and hash is not found, which is the usual "my custom key is not found" bug. Override all three together, derive hash from the same fields, and see Equality, Hashing and Comparison.

  • The best custom keys are immutable value objects. Data.define (Ruby 3.2) gives eql? and hash for free, which is why the shelf Isbn works as a key whichever way the digits were typed.

  • Floating-point keys are fragile because of rounding (0.1 + 0.2 is not 0.3) and Float::NAN, which is never equal to anything, so avoid them.

Mutable keys and rehash

Only String keys are copied and frozen on insertion. Any other mutable key (an Array, a Hash, your own object) is stored as is. If its content changes after it was stored, its hash changes, the Hash looks in the wrong bucket, and the entry becomes unreachable, although iteration still shows it. rehash recomputes the buckets and repairs the table; it is the documented remedy but the better fix is to freeze keys or use immutable ones. Never call rehash while iterating.

Symbols or strings?

Symbols are the conventional keys for a fixed set of names that the program itself defines (options, record fields, anything written as a literal), because they are cheap to compare, are written name:, and are what keyword arguments use. Strings are the conventional keys for data that arrives from outside (JSON, CSV, HTTP headers, environment variables) and for any open-ended set. Do not convert untrusted strings with to_sym in a long-lived process without a bound (the symbol table no longer grows without limit since Ruby 2.2, but dynamic symbols still take memory); JSON.parse(symbolize_names: true) is the safe place to do it for a known document shape. A hash with mixed key types is a smell: h[:a] and h["a"] are different entries, and no automatic indifferent access exists in core Ruby (Rails' with_indifferent_access is not part of Ruby).

compare_by_identity

compare_by_identity switches a hash to compare keys with equal? (object identity) instead of eql?, so two equal Strings become two different keys. It rebuilds the table in place, is reported by compare_by_identity?, is kept by dup, select, reject, merge, slice and transform_values, and suits caching per-object data. String keys of an identity hash are stored as given, not as frozen copies. In inspect the output is the same, so the mode is invisible in logs. For associations that should not keep their keys alive, ObjectSpace::WeakKeyMap and ObjectSpace::WeakMap hold keys weakly.

# frozen_string_literal: true

module Shelf
  Isbn = Data.define(:digits) do
    def initialize(digits:) = super(digits: digits.delete("-"))
  end

  # Defines == only, so a Hash cannot find an equal instance.
  class Plain
    attr_reader :id

    def initialize(id) = @id = id
    def ==(other) = other.is_a?(Plain) && id == other.id
  end

  # Adds eql? and a matching hash: now equal instances are the same key.
  class Keyed < Plain
    alias eql? ==
    def hash = [self.class, id].hash
  end
end

h = { 1 => :int }
h[1.0] # => nil
h[1] # => :int
[1.hash, 1.0.hash].uniq.size # => 2

# Data values hash by content, so equal ISBNs find the same entry.
a = Shelf::Isbn.new("978-0-13-468599-1")
b = Shelf::Isbn.new("9780134685991")
copies = { a => 3 }
copies[b] # => 3
a.equal?(b) # => false
a.eql?(b) # => true
[a.hash, b.hash].uniq.size # => 1

{ Shelf::Plain.new(1) => :found }[Shelf::Plain.new(1)] # => nil
{ Shelf::Keyed.new(1) => :found }[Shelf::Keyed.new(1)] # => :found

# Mutating a key after insertion breaks the lookup until rehash.
path = ["shelf", 1]
located = { path => :here }
path << 2
located[path] # => nil
located[["shelf", 1]] # => nil
located.rehash[path] # => :here

# Compare by identity: equal strings are different keys.
names = {}.compare_by_identity
first = +"ico"
second = +"ico"
names[first] = 1
names[second] = 2
names.size # => 2
names.compare_by_identity? # => true
names["ico".dup] # => nil
names.inspect # => "{\"ico\" => 1, \"ico\" => 2}"
{ a: 1 }.compare_by_identity[:a] # => 1

Equality and inclusion

== is true when both hashes have the same size and each key of one maps to an == value in the other; the order of pairs is irrelevant. eql? demands eql? values (so { a: 1 }.eql?({ a: 1.0 }) is false), and hash is consistent with it, which makes hashes usable as keys in other hashes.

The comparison operators test inclusion. a <= b is true when every pair of a is in b (a subset or equal), a < b when it is a proper subset, and >= and > are the mirror images. Values are compared with ==. This reads naturally for "does this record satisfy the filter": filter <= record. The empty hash is a subset of every hash. The rules, with the order of the operands, are in the documentation page on hash inclusion.

# frozen_string_literal: true

filter = { author: "Ueda" }
book = { title: "Ico", author: "Ueda", year: 2001 }
p(filter <= book) # => true
p(filter < book) # => true
p(book >= filter) # => true
twin = book.dup
p(book > twin) # => false
p(book >= twin) # => true
p(book >= { author: "Bach" }) # => false
p(book >= {}) # => true
p(book >= { year: 2001.0 }) # => true
p(book == { year: 2001, author: "Ueda", title: "Ico" }) # => true
p({ a: 1 } == { a: 1.0 }) # => true
p({ a: 1 }.eql?({ a: 1.0 })) # => false
p({ a: 1 }.hash == { a: 1 }.hash) # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
# => true

matches = [book, { title: "Bach", author: "Bach" }].select { |candidate| filter <= candidate }
matches.map { it[:title] } # => ["Ico"]

The inspect format

Ruby 3.4 changed Hash#inspect (and therefore to_s, p, pp and string interpolation) so that the output looks like the literal you would write today. Symbol keys that are valid as key: print in that form; every other key prints with spaces around the rocket. A Symbol key that would not parse as a bare name: is quoted, which includes names with spaces or punctuation ("my key": 1), operator names ("": 8`), and names with `=`, `[]`, `@` or `$` prefixes. Names that end in `?` or `!` print bare. A recursive hash prints `{…​}+ at the point of recursion, and pp breaks a long hash one pair per line.

Aspect Before 3.4 3.4 and later

Symbol key

{:name=>"Ico"}

{name: "Ico"}

String key

{"isbn"=>"978..."}

{"isbn" => "978..."}

Other key

{1=>:one}

{1 => :one}

Quoted Symbol key

{:"my key"=>1}

{"my key": 1}

Operator Symbol key

{:+=>1}

{"+": 1}

The practical effects are in tests and logs. Any test that compares inspect output to a hard-coded string must be updated when moving to 3.4. Documentation and books written before that show the old format. The new output evaluates back to an equal hash, which the old one also did. All outputs on this page use the 3.4 form.

# frozen_string_literal: true

book = { title: "Ico", "isbn" => "97801", 3 => nil, nil => [1, 2], [1] => { a: :b } }
book.inspect # => "{title: \"Ico\", \"isbn\" => \"97801\", 3 => nil, nil => [1, 2], [1] => {a: :b}}"
p(book.to_s == book.inspect) # => true
puts book
odd = {
  "my key": 1, :"a-b" => 2, a?: 3, b!: 4, C: 5, "9x": 6,
  :[] => 7, :+ => 8, :a= => 9, :@iv => 10, :$g => 11, nil: 12
}
puts odd.inspect
puts({}.inspect)
puts({ a: 1 }.to_a.inspect)
puts :"my key".inspect
entry = { a: 1 }
puts "hash: #{entry}"

loop_hash = { name: "loop" }
loop_hash[:self] = loop_hash
puts loop_hash.inspect

long = { title: "A very long title that goes on", author: "Somebody Else", isbn: "9780134685991", copies: 3 }
pp long
puts long.pretty_inspect.lines.size

Output:

true
{title: "Ico", "isbn" => "97801", 3 => nil, nil => [1, 2], [1] => {a: :b}}
{"my key": 1, "a-b": 2, a?: 3, b!: 4, C: 5, "9x": 6, "[]": 7, "+": 8, "a=": 9, "@iv": 10, "$g": 11, nil: 12}
{}
[[:a, 1]]
:"my key"
hash: {a: 1}
{name: "loop", self: {...}}
{title: "A very long title that goes on",
 author: "Somebody Else",
 isbn: "9780134685991",
 copies: 3}
4

Hashes and keyword arguments

Keyword arguments are Hashes at the boundary. Since Ruby 3.0 the two are separated, which fixed a large family of ambiguities, and the rules are small:

  • f(**options) passes the pairs of options as keywords. Keys must be Symbols unless the method itself accepts **kwargs (Ruby 2.7 allows non-Symbol keys there). Duplicates resolve to the last, as in a literal.

  • f(options), with a Hash as the last positional argument, is not converted to keywords any more. If the method accepts only keywords it raises ArgumentError; if it has an optional positional parameter (def f(opts = {})), the Hash fills it.

  • A braceless hash at the end of a call (f(days: 7)) is keywords if the method accepts keywords, otherwise it becomes a positional Hash. def rest(*args) receives [{days: 7}]; **{} contributes nothing.

  • **nil (Ruby 3.4) at a call site passes nothing; in a parameter list **nil declares that keywords are not accepted.

  • Inside a method, **config collects the keywords into a fresh Hash, which can be modified, merged and forwarded.

  • ** calls to_hash on the operand, so any object with an implicit to_hash converts.

  • ruby2_keywords marks a method that forwards *args so that a trailing keyword Hash stays flagged; it is only for old delegation code (use ... or *, ** in new code), and it is deprecated in the Ruby 4.1 preview.

# frozen_string_literal: true

def lend(isbn, days: 14, renew: false) = "#{isbn} for #{days} days#{', renewable' if renew}"

options = { days: 7, renew: true }
lend("9780134685991", **options) # => "9780134685991 for 7 days, renewable"
lend("9780134685991", **options, days: 3) # => "9780134685991 for 3 days, renewable"

begin
  lend("9780134685991", "days" => 7)
rescue ArgumentError => e
  e.message # => "unknown keyword: \"days\""
end

begin
  lend("9780134685991", options)
rescue ArgumentError => e
  e.message # => "wrong number of arguments (given 2, expected 1)"
end

def settings(**config) = config
settings("days" => 7, :renew => true) # => {"days" => 7, renew: true}
table = { "days" => 7 }
settings(**table) # => {"days" => 7}
settings(**{}) # => {}
settings(**nil) # => {}

def legacy(options = {}) = options
legacy(days: 7) # => {days: 7}
legacy({ days: 7 }) # => {days: 7}
legacy(**options.slice(:days)) # => {days: 7}

def rest(*args) = args
rest(days: 7) # => [{days: 7}]
rest(**{}) # => []
rest(*[], **{}) # => []

def either(positional = :none, **keywords) = [positional, keywords]
either({ days: 7 }) # => [{days: 7}, {}]
either(days: 7) # => [:none, {days: 7}]

def opts_then_block(options, &block) = block ? block.call(options) : options
opts_then_block(days: 7) # => {days: 7}

days = 7
renew = true
{ days:, renew: } # => {days: 7, renew: true}
settings(days:, renew:) # => {days: 7, renew: true}

class Settings
  def initialize(**config) = @config = config
  def to_h = @config.dup
  def to_hash = to_h
end

settings(**Settings.new(days: 3)) # => {days: 3}
settings(**options, **{ days: 1 }.compact) # => {days: 1, renew: true}

Pattern matching with hashes

case/in matches a Hash against a pattern of keys. Only the listed keys are checked, extra keys are allowed unless the pattern ends with **nil, and values can be nested patterns, classes, ranges or bound to variables. hash => pattern raises NoMatchingPatternKeyError (a subclass of NoMatchingPatternError carrying key) when a key is missing, and hash in pattern returns a boolean. A custom class takes part by defining deconstruct_keys(keys), which receives the requested keys or nil. This is the idiomatic way to unpack parsed JSON payloads. The full syntax is in Pattern Matching.

# frozen_string_literal: true

event = { type: "loan", book: { isbn: "9780134685991", title: "Ico" }, days: 14 }

summary = case event
          in { type: "loan", book: { title: }, days: Integer => days }
            "#{title} lent for #{days} days"
          end
p summary # => "Ico lent for 14 days"

event => { book: { isbn: } }
p isbn # => "9780134685991"

event in { type: "return" } # => false
event in { type: String } # => true
event in { days: 1.. } # => true

extra = { type: "loan", extra: 1 }
kind = case extra
       in { type: "loan", **nil } then :exact
       in { type: "loan", **rest } then rest
       end
p kind # => {extra: 1}

begin
  event => { missing: _field }
rescue NoMatchingPatternKeyError => e
  e.message # => "{type: \"loan\", book: {isbn: \"9780134685991\", title: \"Ico\"}, days: 14}: key not found: :missing"
  e.key # => :missing
end

# A custom class takes part by defining deconstruct_keys.
class Point
  attr_reader :x_pos, :y_pos

  def initialize(x_pos, y_pos)
    @x_pos = x_pos
    @y_pos = y_pos
  end

  def deconstruct_keys(keys) = { x: x_pos, y: y_pos }.slice(*(keys || %i[x y]))
end

case Point.new(1, 2)
in { x:, y: } then [x, y] # => [1, 2]
end

Copying, freezing and sharing

  • dup and clone make shallow copies: a new table, the same key and value objects. Changing a nested Array through the copy changes it in the original. clone also keeps the frozen state and singleton methods (clone(freeze: false) overrides), while dup does not.

  • dup, clone, merge, compact and to_h keep the default value or default proc; select, reject, slice, except, transform_values and to_h with a block do not.

  • A deep copy needs a deliberate step. Marshal.load(Marshal.dump(hash)) works for plain data, but a hash with a default proc cannot be dumped (TypeError: can’t dump hash with default proc); recursive helpers that map over values are clearer. Never feed Marshal.load data you did not produce.

  • Ractor.make_shareable(hash) freezes the hash and everything reachable from it in place, which is the idiom for constant configuration (CONFIG = Ractor.make_shareable(...)) and what the freezing section of Object Lifecycle, Freezing and Copying and Ractors build on.

# frozen_string_literal: true

original = { title: "Ico", tags: ["game"], stats: Hash.new(0) }
shallow = original.dup
shallow[:title] = "Changed"
shallow[:tags] << "ps2"
original[:title] # => "Ico"
original[:tags] # => ["game", "ps2"]
shallow[:stats]["x"] # => 0
shallow.default_proc # => nil
shallow.equal?(original) # => false
shallow[:tags].equal?(original[:tags]) # => true

deep = Marshal.load(Marshal.dump({ title: "Ico", tags: ["game"] }))
deep[:tags] << "extra"
p deep # => {title: "Ico", tags: ["game", "extra"]}

counting = Hash.new(7)
counting.dup[:missing] # => 7
counting.clone.default # => 7
counting.merge({}).default # => 7
counting.select { true }.default # => nil
counting.to_h.default # => 7
counting.to_h { |k, v| [k, v] }.default # => nil

locked = { tags: ["game"] }.freeze
locked.dup.frozen? # => false
locked.clone.frozen? # => true
locked.clone(freeze: false).frozen? # => false
locked[:tags] << "still mutable"
p locked # => {tags: ["game", "still mutable"]}

nested = { a: { b: 1 } }
frozen_all = Ractor.make_shareable(nested)
frozen_all.frozen? # => true
frozen_all[:a].frozen? # => true
nested.equal?(frozen_all) # => true

JSON and other text formats

A parsed JSON object is a Hash whose keys are Strings by default. JSON.parse(text, symbolize_names: true) makes them Symbols, which is what dig(:author, :name) and pattern matching with key: need. In the other direction to_json and JSON.generate turn keys into JSON strings by calling to_s, so non-String keys collapse silently: nil becomes "" and an Array becomes its inspect text. Round-tripping therefore changes Symbols to Strings. The json library also offers JSON.pretty_generate; YAML (Psych) and CSV have their own Hash conversions, covered in Serialization: JSON, YAML, CSV and Marshal.

# frozen_string_literal: true

require "json"

payload = '{"title":"Ico","copies":3,"tags":["game"],"author":{"name":"Ueda"}}'
JSON.parse(payload) # => {"title" => "Ico", "copies" => 3, "tags" => ["game"], "author" => {"name" => "Ueda"}}
data = JSON.parse(payload, symbolize_names: true)
p data # => {title: "Ico", copies: 3, tags: ["game"], author: {name: "Ueda"}}
data.dig(:author, :name) # => "Ueda"
JSON.generate(data) # => "{\"title\":\"Ico\",\"copies\":3,\"tags\":[\"game\"],\"author\":{\"name\":\"Ueda\"}}"
p(data.to_json == JSON.generate(data)) # => true
{ 1 => :a, nil => :b, [1] => :c }.to_json # => "{\"1\":\"a\",\"\":\"b\",\"[1]\":\"c\"}"
JSON.parse(JSON.generate({ a: 1 })) # => {"a" => 1}
JSON.pretty_generate({ a: { b: [1, 2] } }).lines.size # => 8

Choosing between Hash, Struct, Data and a class

A Hash is the right tool for data whose keys are not known in advance or vary per instance: counters, indexes, caches, grouped results, parsed JSON, option bags. It is the wrong tool for a record with a fixed set of fields, where a typo in a key silently yields nil and nothing documents or validates the shape.

Need Use Why

Unknown, dynamic or large key set; lookup by key

Hash

Constant-time lookup; any object can be a key.

Fixed fields, immutable, value equality

Data.define

Named reader methods, with, pattern matching, keys-safe eql?/hash, no setters.

Fixed fields, mutable, positional or keyword construction

Struct

Accessors and setters, to_h, members.

Fixed fields plus behaviour and validation

A class

Methods, encapsulation, initialize checks.

A set of unique values with no payload

Set

Membership and set algebra without dummy values.

Keyword options for one method

Keyword parameters

Checked by the interpreter (unknown keyword) and documented in the signature.

Two common upgrades: convert a record-like Hash at the boundary into a Data (Shelf::Book.new(**attributes)) and work with that inside the program, and replace OpenStruct (a bundled gem since Ruby 4.0 and slow) with Struct or Data. See Struct, Data and Value Objects.

Errors and warnings

Message Class Typical cause

key not found: :b

KeyError

fetch, fetch_values or a pattern with a missing key; key and receiver are available.

odd number of arguments for Hash

ArgumentError

Hash[a, b, c] with an odd count.

wrong array length at 0 (expected 2, was 3)

ArgumentError

to_h on an array whose element is not a pair.

wrong element type Integer at 0 (expected array)

TypeError

to_h on an array containing non-arrays.

can't add a new key into hash during iteration

RuntimeError

Assigning a new key inside each.

can't modify frozen Hash: {a: 1}

FrozenError

Mutating a frozen hash.

no implicit conversion of Symbol into Integer

TypeError

Indexing a value that turned out to be an Array (or Integer) with a Symbol.

undefined method '[]' for nil

NoMethodError

Chained [] on a missing intermediate value; use dig or fetch.

Integer does not have #dig method

TypeError

dig walked into a value that cannot dig.

no implicit conversion of Integer into Hash

TypeError

merge or transform_keys given a non-hash.

comparison of Symbol with String failed

ArgumentError

Sorting keys of mixed types.

unknown keyword: "days"

ArgumentError

A String-keyed hash splatted into a method with named keywords.

wrong number of arguments (given 1, expected 2)

ArgumentError

A two-parameter lambda passed with & to each or map.

# frozen_string_literal: true

def failure
  yield
rescue StandardError => e
  "#{e.class}: #{e.message}"
end

failure { Hash[1, 2, 3] } # rubocop:disable Style/HashConversion
# => "ArgumentError: odd number of arguments for Hash"
failure { [[1, 2, 3]].to_h } # => "ArgumentError: wrong array length at 0 (expected 2, was 3)"
failure { [1].to_h } # => "TypeError: wrong element type Integer at 0 (expected array)"
failure { { a: 1 }.fetch(:b) } # => "KeyError: key not found: :b"
failure { { a: 1 }.fetch_values(:a, :b) } # => "KeyError: key not found: :b"
failure { { a: 1 }.freeze[:b] = 2 } # => "FrozenError: can't modify frozen Hash: {a: 1}"
shelf = { a: 1 }
failure { shelf.each { shelf[:b] = 2 } } # => "RuntimeError: can't add a new key into hash during iteration"
failure { { a: 1 }[:a][:b] } # => "TypeError: no implicit conversion of Symbol into Integer"
failure { { a: nil }[:a][:b] } # => "NoMethodError: undefined method '[]' for nil"
failure { { a: 1 }.dig(:a, :b) } # => "TypeError: Integer does not have #dig method"
failure { { a: 1 }.merge(1) } # => "TypeError: no implicit conversion of Integer into Hash"
failure { Hash.new(1, 2) } # => "ArgumentError: wrong number of arguments (given 2, expected 0..1)"
failure { { a: 1 }.transform_keys(1) } # => "TypeError: no implicit conversion of Integer into Hash"
failure { { a: 1, "b" => 2 }.keys.sort } # => "ArgumentError: comparison of Symbol with String failed"
failure { { a: 1 }.sum } # => "TypeError: Array can't be coerced into Integer"

Two warnings come from ruby -w (or $VERBOSE = true): key …​ is duplicated and overwritten on line N for repeated literal keys, and block supersedes default value argument for fetch with both a fallback and a block. The next file contains both mistakes on purpose, so RuboCop’s Lint/DuplicateHashKey and Lint/UselessDefaultValueArgument are disabled on those two lines.

# frozen_string_literal: true

# Deliberate mistakes: run with `ruby -w` to see the warnings.
stock = { "Ico" => 3, "Ico" => 4, ico: 1, ico: 2 } # rubocop:disable Lint/DuplicateHashKey
p stock
p({ a: 1 }.fetch(:a, 0) { 99 }) # rubocop:disable Lint/UselessDefaultValueArgument

Output with ruby -w:

{"Ico" => 4, ico: 2}
1
e19.rb:4: warning: key "Ico" is duplicated and overwritten on line 4
e19.rb:4: warning: key :ico is duplicated and overwritten on line 4
e19.rb:6: warning: block supersedes default value argument

Threads and performance notes

Threads. A Hash has no internal locking. On MRI the global VM lock makes single operations such as []= safe from corrupting the table, but a compound step (check, then insert; h[k] += 1; a default proc that stores) is not atomic, and other Ruby implementations do not offer even that guarantee. Share a mutable hash between threads only behind a Thread::Mutex, as the shelf Catalog does with its store, or share frozen data. A mutable hash cannot be reached from another Ractor unless it is made shareable (deeply frozen) first. See Threads and the GVL.

Performance. Lookups, inserts and deletes are constant time on average; building a hash from n pairs is linear. Practical guidance, without microbenchmarks that would age badly:

  • Pass capacity: (3.4) to Hash.new when a large hash is built incrementally and the size is known.

  • Symbol and Integer keys hash faster than long Strings, whose hash is computed from their bytes; do not convert the keys of a large external dataset to Symbols just for speed.

  • compare_by_identity avoids calling hash and eql? on the keys and suits per-object caches.

  • merge allocates a new table; in a loop that accumulates, merge!/update or each_with_object avoid the copies.

  • transform_values and to_h { } build the result in one pass; map { }.to_h allocates an intermediate Array.

  • keys.each or values.each allocate Arrays; prefer each_key and each_value.

  • value?, key and rassoc scan every pair; if reverse lookup is frequent, keep an inverted hash.

For measuring rather than guessing, see Performance, Benchmarking and Profiling.

Where older books and tutorials are outdated

Older claim or idiom Ruby 4.0 reality

Hashes print as {:name=>"Ico"}

Since 3.4 they print as {name: "Ico"}; update any test that compares inspect text.

Hashes are unordered

Insertion order is guaranteed (since 1.9), including for first, shift and each.

Hash#select and reject return Arrays (Ruby 1.8)

They return Hashes; map and most other Enumerable methods still return Arrays.

Hash#index(value) finds a key

Removed; use key(value).

except, symbolize_keys, deep_merge, with_indifferent_access come from ActiveSupport

except is core since 3.0. symbolize_keys is transform_keys(&:to_sym), deep_merge needs a recursive merge block, and indifferent access is not in core.

Options are passed as a trailing options Hash that converts to keywords

Positional and keyword arguments are separated since 3.0; use **options explicitly.

Hash#each yields two values to a lambda

It yields one two-element array (3.0); only blocks and procs destructure.

Build hashes with inject({}) or map { }.to_h

Use to_h { }, each_with_object, tally, group_by, transform_values and filter_map.

Hash#shift on an empty hash returns the default

It returns nil (3.2).

OpenStruct for dot access to a hash

Use Data or Struct; OpenStruct is a bundled gem since 4.0.

References