Ruby Hash Methods: The Practical Cheat Sheet

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.

Reading values

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.

Checking what's there

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.

Slicing and trimming

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.

Merging

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.

Transforming

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.

Filtering

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.

Building hashes from other things

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.

Defaults

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.

Comparing

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.

Mutation and copies

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.

Quick reference

Keep this kind of thing coming

Practical Ruby and Rails articles, in your inbox when they publish.