Hash Tricks in Ruby

· 6 min read · Updated March 31, 2026 · intermediate
ruby hash ruby-2-5 ruby-3-0

Ruby hashes are deceptively deep. Once you know how to [] and []= your way around, a whole arsenal of methods sits waiting — methods that eliminate boilerplate, prevent common bugs, and make code genuinely pleasant to read. This guide covers the Hash tricks that change how you write Ruby day-to-day.

transform_keys and transform_values

Ruby 2.5 introduced two convenience methods that save you from writing each_with_object every time you need to transform a hash’s keys or values.

user = { first_name: "John", last_name: "Doe" }
user.transform_keys { |k| k.to_s }
# => { "first_name" => "John", "last_name" => "Doe" }

Common use cases: normalising keys for an API payload, or converting symbol-keyed hashes to string-keyed ones in a single call. The original hash stays unchanged.

For values, the same pattern applies:

prices = { apple: 1.0, banana: 0.5, cherry: 2.0 }
prices.transform_values { |v| v * 1.1 }
# => { apple: 1.1, banana: 0.55, cherry: 2.2 }

Ruby 3.0 made these chainable, which enables concise transformation pipelines:

h = { a: 1, b: 2 }
h.transform_keys(&:to_s).transform_values { |v| v * 2 }
# => { "a" => 2, "b" => 4 }

Both methods have in-place variants (transform_keys! and transform_values!) if you need to mutate the original.

dig — Safe Nested Access

Hash#dig (Ruby 2.3+) safely navigates nested hashes without manual nil-checking at every level. If any step in the path is nil or missing, it returns nil instead of raising an error.

config = {
  db: {
    primary: { host: "localhost", port: 5432 }
  }
}

config.dig(:db, :primary, :host)   # => "localhost"
config.dig(:db, :replica, :host)   # => nil  (no error)
config.dig(:cache, :redis, :host)  # => nil  (no error)

Without dig, the equivalent is verbose:

config[:db] && config[:db][:primary] && config[:db][:primary][:host]

dig also handles arrays mixed into the path:

data = { users: [{ name: "Alice" }, { name: "Bob" }] }
data.dig(:users, 1, :name)  # => "Bob"

fetch — Explicit Lookup with Fallbacks

Hash#fetch differs from [] in that it gives you control over what happens when a key is absent:

h = { count: 42 }

h.fetch(:count, 0)            # => 42
h.fetch(:missing, 0)          # => 0

# Block runs only when the key is absent
h.fetch(:missing) { |k| "Key #{k} not found" }
# => "Key :missing not found"

# Without a default or block, fetch raises KeyError
h.fetch(:missing)  # => KeyError: key not found: :missing

This is especially useful when nil is a legitimate stored value — [] returns nil whether the key is absent or explicitly set to nil, but fetch with no default raises instead.

Hash.new with a Block — Lazy Defaults

Hash.new accepts a block that acts as a default proc. The block is called fresh for each missing key, receiving the hash and the key as arguments:

# WRONG — all absent keys share one array instance
h = Hash.new([])
h[:a] << 1
h[:b] << 2
h[:a]  # => [1, 2]  — :b's append leaked in
h[:c]  # => [1, 2]  — the default itself is mutated
h      # => {}      — nothing is actually stored
# RIGHT — block is called fresh each time
h = Hash.new { |hash, key| [] }
h[:a] << 1
h[:b] << 2
h[:a]  # => [1]
h[:b]  # => [2]
h      # => { a: [1], b: [2] }

The auto-vivification pattern uses the block to assign a value on first access:

counts = Hash.new { |h, k| h[k] = 0 }
counts[:apple] += 1
counts[:banana] += 1
counts[:apple]   # => 1
counts[:banana]  # => 1

For recursively auto-vivifying nested hashes, pass the hash’s own default proc:

tree = Hash.new { |h, k| h[k] = Hash.new(&h.default_proc) }

tree[:users][:alice][:name] = "Alice"
tree[:users][:bob][:name] = "Bob"

tree
# => { users: { alice: { name: "Alice" }, bob: { name: "Bob" } } }

merge — Combining Hashes

Hash#merge combines two hashes, with the argument’s values winning on conflict:

defaults = { theme: "light", lang: "en" }
user_prefs = { theme: "dark" }
defaults.merge(user_prefs)  # => { theme: "dark", lang: "en" }

When both hashes share a key and you need more than “right wins,” pass a block:

base = { items: [1, 2], count: 5 }
overlay = { items: [3], count: 10 }

base.merge(overlay) { |key, old, new| key == :items ? old + new : new }
# => { items: [1, 2, 3], count: 10 }

Ruby 3.0 added support for merging multiple hashes in one call:

h1.merge(h2, h3, h4)

Use merge! (or its alias update) to mutate in place.

slice — Selecting Keys

Hash#slice (Ruby 2.5+) returns a new hash containing only the specified keys:

params = { name: "John", email: "john@example.com", admin: true, _token: "abc" }
params.slice(:name, :email)
# => { name: "John", email: "john@example.com" }

This is useful for whitelisting permitted params before passing them somewhere sensitive. Its inverse is except, which removes specified keys instead.

Note that Ruby’s stdlib slice returns an empty hash for non-existent keys — it does not raise.

compact — Removing nil Values

Ruby 3.0 added Hash#compact and compact! to drop entries where the value is nil:

data = { a: 1, b: nil, c: 3, d: nil }
data.compact   # => { a: 1, c: 3 }
data.compact!  # => { a: 1, c: 3 }
data           # => { a: 1, c: 3 }  (mutated)

For Ruby 2.x, the equivalent is:

hash.reject { |_k, v| v.nil? }

Struct and OpenStruct — Dot-Notation Access

Sometimes you want to access hash data with dot notation instead of []. Ruby’s standard library provides two options.

OpenStruct creates an object with arbitrary attributes from a hash:

require "ostruct"

data = { name: "Carol", age: 30, city: "London" }
person = OpenStruct.new(data)

person.name   # => "Carol"
person.age    # => 30
person.city   # => "London"

person.email = "carol@example.com"

Ruby 2.4 added to_ostruct directly on Hash:

hash = { name: "Dave", lang: "Ruby" }
hash.to_ostruct.name  # => "Dave"

For fixed schemas, Struct is faster since it defines actual accessor methods at class creation time:

User = Struct.new(:name, :email, :role)
user = User.new("Alice", "alice@example.com", "admin")
user.name    # => "Alice"
user.email   # => "alice@example.com"

A Real-World Example

Putting several of these tricks together — cleaning incoming form params:

raw_params = {
  "first_name" => "John",
  "last_name"  => "Doe",
  "age"        => "30",
  "admin"      => "true",
  "csrf_token" => "abc123"
}

ALLOWED_KEYS = [:first_name, :last_name, :age]

clean = raw_params
  .transform_keys { |k| k.gsub(" ", "_").to_sym }
  .slice(*ALLOWED_KEYS)
  .transform_values { |v| v.to_i }

p clean
# => { first_name: "John", last_name: "Doe", age: 30 }

Three lines: normalise keys, whitelist only what you need, and cast types.

Common Gotchas

Symbol keys and string keys are different. {"name" => "Alice"}[:name] returns nil. This trips people up when mixing JSON (string keys) with Ruby hashes (symbol keys). Use transform_keys to normalise.

merge does not deep-merge by default. Overlapping nested hashes are replaced wholesale:

base = { user: { name: "Alice", role: "admin" } }
overlay = { user: { role: "superadmin" } }
base.merge(overlay)
# => { user: { role: "superadmin" } }  — :name is gone

A recursive merge resolves this.

fetch and [] have different missing-key behaviour. h[:x] returns nil silently; h.fetch(:x) raises KeyError unless given a default. Mixing them on the same hash leads to subtle bugs — pick one approach and stay consistent.

See Also

  • Hash#dig — safely access nested hash paths
  • Hash#fetch — key lookup with explicit fallback control
  • Hash#merge — combining hashes with conflict resolution