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 theirhashvalues are equal. That is why"a"finds"a"even when they are different String objects, and why1and1.0are 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.fetchandkey?ignore the default and tell the truth. -
A Hash is
Enumerable.eachyields two-element[key, value]arrays, somap,select,sort_by,group_by,sumand 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: valuemakes a Symbol key. It is the standard form and the one the 3.4inspectprints. -
"text": valuemakes 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’sStyle/HashSyntaxrewrites. -
x:with no value (Ruby 3.1) is shorthand forx: 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 |
|---|---|---|
|
An empty hash |
RuboCop prefers the literal |
|
An empty hash whose default value is |
Positional default; see Defaults and default procs. |
|
An empty hash with a default proc |
The block runs for every missing key. |
|
An empty hash preallocated for |
Ruby 3.4. Saves reallocation when a large hash is built incrementally. |
|
A hash from pairs or from an even-length list |
An odd list raises |
|
A hash from an array of two-element arrays |
Raises |
|
A hash from any |
Since Ruby 2.6. Replaces |
|
A hash from two parallel lists |
Extra keys get |
|
The object converted by |
Accepts a Hash, |
|
The hash from |
Returns |
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.
-
deleteremoves the key, so adding it again places it last. -
shiftremoves and returns the first pair (nilfor an empty hash since Ruby 3.2; earlier versions returned the default). -
first,first(n)andmin_byall see that order. Equality ignores it: two hashes with the same pairs in different order are==andeql?. -
Sorting never reorders a Hash in place:
sort,sort_byandmin_byreturn Arrays of pairs; use.to_hto 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
Source: Ruby 4.0 documentation: Hash
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 |
|---|---|---|
|
Default value or |
Triggers the default proc. |
|
|
Ignores the hash’s default completely. |
|
The fallback |
Both given: the block wins and Ruby warns. |
|
One entry per key, using the default |
Always returns an Array of the same length. |
|
|
A block supplies a fallback per missing key. |
|
|
Walks nested structures; see below. |
|
|
All four are aliases; they never use the default. |
|
|
Linear scan, compares with |
|
|
The first key whose value is |
|
|
Return the |
|
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 withnilif a value isnil. -
The intermediate object must itself respond to
dig: Hash, Array and Struct do. Anything else (a String, an Integer) raisesTypeErrorwith the messageInteger does not have #dig method.Datahas nodig. -
digwith one argument is just[]; RuboCop’sStyle/SingleArgumentDigflags 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 dodigandvalues_at, which call it.fetch,fetch_values,key?,slice,to_a,sizeandeachdo 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. Assigningdefault=clears a default proc anddefault_proc=clears the default value. Thedefault_proc=argument must accept two arguments (a lambda that takes one raisesTypeError). -
Copies made with
dup,clone,merge,compactandto_hkeep the default;select,reject,slice,except,transform_valuesandto_hwith 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 formHash.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
Source: Ruby 4.0 documentation: Hash.new
Modifying a hash
| Method | What it does | Returns |
|---|---|---|
|
Adds or replaces |
The value |
|
Removes the pair; the block runs when the key is absent |
The removed value, or the block’s result, or |
|
Removes pairs for which the block is true |
The hash ( |
|
Keeps only pairs for which the block is true |
The hash ( |
|
Removes |
The hash, or |
|
A new hash with the other hashes applied left to right; the block resolves conflicts |
A new hash; with no arguments, a copy |
|
The same, in place |
The receiver |
|
Rewrites values or keys in place |
The receiver |
|
Makes the receiver a copy of |
The receiver |
|
Empties the hash; removes and returns the first pair |
The hash; the pair or |
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_keyandeach_valueiterate 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,invertandto_h. Nearly everything else (map,sort_by,partition,min_by,group_byvalues,zip,flatten,sort) returns an Array. Add.to_hto get a Hash back. -
A numbered parameter or
itreceives the whole pair when used alone:hash.map { it }yields[key, value]arrays;_1and_2destructure it.it(Ruby 3.4) is a good fit when the pair is passed through; for two named parts prefer|key, value|. RuboCop’sStyle/NumberedParametersLimitforbids using more than one numbered parameter. -
A lambda passed with
&does not destructure: since Ruby 3.0Hash#eachandmapalways yield a single two-element Array, so a lambda taking two parameters raisesArgumentError. Aproc(or a block) destructures. Write->(pair) { ... }or use a block. -
The order is the insertion order, so
first,each_sliceandtake_whileare deterministic. -
Unused block parameters are conventionally prefixed with
_(|_title, copies|), which RuboCop’sLint/UnusedBlockArgumentexpects.
# 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,compactfilter 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. -
invertswaps keys and values; duplicate values collapse (the last key wins). -
group_byreturns a Hash of Arrays, grouped by the block’s result;partitionreturns two Arrays;tally(2.7) counts equal elements into a Hash, and since 3.1 takes a hash to add to.each_with_objectbuilds any shape. -
sum,min_by,max_byandsort_bytake 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.0is true but1.eql?(1.0)is false and their hashes differ, so{ 1 => :int }[1.0]isnil. The same holds for1andRational(1). -
Strings, Symbols, Integers, Floats, Arrays, Hashes, Ranges,
StructandDatainstances compare and hash by content, so they work as keys. A plainObjecthashes by identity. -
A class that overrides
==but noteql?andhashis not found, which is the usual "my custom key is not found" bug. Override all three together, derivehashfrom the same fields, and see Equality, Hashing and Comparison. -
The best custom keys are immutable value objects.
Data.define(Ruby 3.2) giveseql?andhashfor free, which is why the shelfIsbnworks as a key whichever way the digits were typed. -
Floating-point keys are fragile because of rounding (
0.1 + 0.2is not0.3) andFloat::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 |
|
|
String key |
|
|
Other key |
|
|
Quoted Symbol key |
|
|
Operator Symbol key |
|
|
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 ofoptionsas 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 raisesArgumentError; 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**nildeclares that keywords are not accepted. -
Inside a method,
**configcollects the keywords into a fresh Hash, which can be modified, merged and forwarded. -
**callsto_hashon the operand, so any object with an implicitto_hashconverts. -
ruby2_keywordsmarks a method that forwards*argsso 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
-
dupandclonemake shallow copies: a new table, the same key and value objects. Changing a nested Array through the copy changes it in the original.clonealso keeps the frozen state and singleton methods (clone(freeze: false)overrides), whiledupdoes not. -
dup,clone,merge,compactandto_hkeep the default value or default proc;select,reject,slice,except,transform_valuesandto_hwith 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 feedMarshal.loaddata 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
Source: Ruby 4.0 documentation: JSON
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 |
|
Constant-time lookup; any object can be a key. |
Fixed fields, immutable, value equality |
|
Named reader methods, |
Fixed fields, mutable, positional or keyword construction |
|
Accessors and setters, |
Fixed fields plus behaviour and validation |
A class |
Methods, encapsulation, |
A set of unique values with no payload |
|
Membership and set algebra without dummy values. |
Keyword options for one method |
Keyword parameters |
Checked by the interpreter ( |
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Assigning a new key inside |
|
|
Mutating a frozen hash. |
|
|
Indexing a value that turned out to be an Array (or Integer) with a Symbol. |
|
|
Chained |
|
|
|
|
|
|
|
|
Sorting keys of mixed types. |
|
|
A String-keyed hash splatted into a method with named keywords. |
|
|
A two-parameter lambda passed with |
# 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"
Source: Ruby 4.0 documentation: KeyError
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) toHash.newwhen 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_identityavoids callinghashandeql?on the keys and suits per-object caches. -
mergeallocates a new table; in a loop that accumulates,merge!/updateoreach_with_objectavoid the copies. -
transform_valuesandto_h { }build the result in one pass;map { }.to_hallocates an intermediate Array. -
keys.eachorvalues.eachallocate Arrays; prefereach_keyandeach_value. -
value?,keyandrassocscan 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 |
Since 3.4 they print as |
Hashes are unordered |
Insertion order is guaranteed (since 1.9), including for |
|
They return Hashes; |
|
Removed; use |
|
|
Options are passed as a trailing options Hash that converts to keywords |
Positional and keyword arguments are separated since 3.0; use |
|
It yields one two-element array (3.0); only blocks and procs destructure. |
Build hashes with |
Use |
|
It returns |
|
Use |
References
-
Ruby 3.0.0 NEWS, 3.1.0, 3.2.0 and 3.4.0 (
Hash#except, value omission,shift,inspect,capacity:) -
The Ruby Style Guide and RuboCop cops (
Style/HashSyntax,Style/HashConversion,Lint/SharedMutableDefault,Lint/DuplicateHashKey,Style/SingleArgumentDig)