The Hash methods you reach for in real Ruby and Rails work, with runnable examples and the gotchas next to the methods that cause them. Everything here runs in plain irb unless marked as ActiveSupport.
Four ways to read, four different failure modes. Picking the right one
is the difference between a clear error and a nil that
surfaces three method calls later.
h = {name: "Jane", role: "admin"}
h[:name] # => "Jane"
h[:missing] # => nil, silently
h.fetch(:missing) # => KeyError, loudly, at the actual problem
h.fetch(:missing, "default") # => "default"
h.fetch(:missing) { |k| load(k) } # => lazy default, only runs on miss
For nested structures, dig walks down without raising on
the way:
user = {profile: {address: {city: "Brisbane"}}}
user.dig(:profile, :address, :city) # => "Brisbane"
user.dig(:profile, :phone, :area) # => nil, no NoMethodError
user[:profile][:phone][:area] # => NoMethodError on nil
The tradeoff: dig never tells you which level was missing.
When a wrong nil matters, prefer chained
fetch. There is a longer discussion of this in
the fetch article.
h = {name: "Jane", note: nil}
h.key?(:name) # => true (has_key? and include? are aliases)
h.key?(:note) # => true, a nil value still counts
h.value?(nil) # => true
h.any? { |k, v| v.nil? } # => true
h.empty? # => false
h.size # => 2 key? versus a truthiness check on h[:note] is
a classic source of bugs: the key exists, the value is
nil, and if h[:note] treats it as absent.
h = {id: 1, name: "Jane", password_digest: "...", role: "admin"}
h.slice(:id, :name) # => {id: 1, name: "Jane"}
h.except(:password_digest) # => everything but that key
h.compact # => drops keys whose value is nil
h.delete(:role) # => "admin", and mutates h slice and except return new hashes and are
the tidy way to build API responses or log payloads without leaking
fields. delete mutates and returns the removed value,
which makes it handy for option hashes: timeout =
opts.delete(:timeout) || 30.
defaults = {retries: 3, timeout: 30}
opts = {timeout: 5}
defaults.merge(opts) # => {retries: 3, timeout: 5}, right side wins
defaults.merge(opts) { |key, old, new| [old, new].min } # resolve conflicts yourself
Plain merge is shallow. Nested hashes get replaced
wholesale, not combined:
a = {db: {host: "localhost", port: 5432}}
b = {db: {port: 6432}}
a.merge(b) # => {db: {port: 6432}}, host is gone
a.deep_merge(b) # => {db: {host: "localhost", port: 6432}} (ActiveSupport) deep_merge comes from ActiveSupport, so it is free in
Rails and a small dependency or hand-rolled recursion elsewhere. Losing
a nested key to a shallow merge is one of the quietest bugs in config
handling.
h = {"name" => "Jane", "role" => "admin"}
h.transform_keys(&:to_sym) # => {name: "Jane", role: "admin"}
h.transform_values(&:upcase) # => {"name" => "JANE", "role" => "ADMIN"}
h.transform_keys(name: :full_name) # => rename specific keys (Ruby 3.0+)
# Rails only:
h.symbolize_keys
h.deep_symbolize_keys # recurses into nested hashes
Whether your keys are symbols or strings is worth being deliberate
about, because :name and "name" are different
keys and mixing them produces hashes that look right and behave wrong.
That whole failure mode has
its own article.
h = {a: 1, b: 2, c: 3}
h.select { |k, v| v > 1 } # => {b: 2, c: 3} (filter is an alias)
h.reject { |k, v| v > 1 } # => {a: 1}
h.min_by { |k, v| v } # => [:a, 1]
h.sort_by { |k, v| -v }.to_h # => {c: 3, b: 2, a: 1}
Note that sort_by, min_by, and friends come
from Enumerable and return arrays of pairs; call to_h when
you want a hash back.
users = [["jane", :admin], ["sam", :viewer]]
users.to_h # => {"jane" => :admin, "sam" => :viewer}
%w[a b c].each_with_index.to_h # => {"a" => 0, "b" => 1, "c" => 2}
names = %w[jane sam jane]
names.tally # => {"jane" => 2, "sam" => 1}
names.group_by(&:length) # => {4 => ["jane", "jane"], 3 => ["sam"]}
# to_h with a block maps pairs in one pass:
h = {a: 1, b: 2}
h.to_h { |k, v| [k.to_s, v * 10] } # => {"a" => 10, "b" => 20} each_with_object({}) remains the workhorse for
accumulating anything more complicated, and
compares favourably
with reduce for readability.
counts = Hash.new(0)
counts[:x] += 1 # => 1, no nil check needed
lists = Hash.new { |h, k| h[k] = [] }
lists[:a] << 1 # => {a: [1]}
# The trap: a default OBJECT is shared across keys.
bad = Hash.new([])
bad[:a] << 1
bad[:b] # => [1], surprise, same array The shared-object trap is common enough that hash defaults have their own article. Rule of thumb: immutable default, value form; mutable default, block form with assignment.
a = {name: "Jane", role: "admin"}
b = {role: "admin", name: "Jane"}
a == b # => true, order never matters for equality
small = {role: "admin"}
small <= a # => true, subset check (Ruby 2.3+)
a.filter_map { |k, v| k unless b[k] == v } # keys that differ
Equality is easy; seeing what differs between two large nested
hashes is not, especially in failing test output. That is the problem
the RubyHash diff tool exists for: paste both hashes
and it highlights the changed keys, including type changes like
nil to String or Integer to
Float. For the same fix directly in your terminal, the
minitest-hashdiff
gem replaces Minitest's default output for failing hash assertions,
zero configuration.
h = {meta: {tags: ["a"]}}
copy = h.dup
copy[:meta][:tags] << "b"
h # => {meta: {tags: ["a", "b"]}}, dup is shallow! dup and clone copy one level only; nested
structures are shared. For a true deep copy of plain data, marshal
round-tripping or ActiveSupport's deep_dup are the honest
options. Frozen hashes (increasingly common with frozen literals) raise
FrozenError on any mutating method, which is a feature:
it finds accidental mutation for you.
[], fetch, dig, values_atkey?, value?, empty?, any?slice, except, compact, deletemerge, deep_merge (AS)transform_keys, transform_values, to_h with blockselect, reject, sort_by, group_by, tally==, <=, and RubyHash when the diff mattersPractical Ruby and Rails articles, in your inbox when they publish.