rubyguides

Practical hash tricks in Ruby for day-to-day data work

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 easier to scan. These hash tricks are the difference between a noisy hash loop and a small, readable data pipeline.

The practical question is not whether Hash has useful helpers. It is which helper fits the shape of the data in front of you. Practical hash work in Ruby often means picking the right method for the right job. Use transform_keys when the keys need cleanup, dig when nested data might be missing, fetch when absence should be explicit, and compact when nil values are just clutter.

Key takeaways

  • transform_keys and transform_values are the fastest way to reshape simple hashes without hand-written loops.
  • dig is ideal for JSON-like structures where one missing layer should return nil.
  • fetch is the right choice when an absent key should raise or use a fallback explicitly.
  • Hash.new with a block is the safest path for auto-vivification and per-key defaults.
  • Struct and OpenStruct are useful when a hash starts behaving like a small record object.

When you work through the examples below, keep one rule in mind: prefer the method that makes missing data obvious. A hash trick should reduce branching, not hide important edge cases.

Which hash trick should you use?

If the data shape is simple and the goal is cleanup, start with transform_keys, transform_values, or compact. If the structure is nested or optional, reach for dig or fetch before you add manual nil checks. If the hash starts behaving like a record object, consider Struct or OpenStruct instead of adding more ad hoc access methods.

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.

That makes these methods especially good for the boundary between Ruby and the outside world. Incoming JSON often uses strings, but your Ruby code may want symbols. Outgoing payloads sometimes need the reverse. These helpers keep that conversion in one place instead of scattering to_s and to_sym calls around the codebase.

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. Chaining means you can call transform_keys and transform_values back-to-back without storing the intermediate hash in a variable. Each method returns a fresh hash, so the next method in the chain receives clean output and the original hash stays unchanged. This approach works well when the data needs both key normalization and value conversion before it reaches the rest of your code:

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.

If you are building a normalization step for a controller, a service object, or a background job, chaining these methods is often clearer than one large loop. The hash still looks like a hash, and the transformation stays easy to read.

dig for 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 a chain of && guards that checks each level before stepping deeper. This pattern was common before Ruby 2.3, and it works, but it grows linearly with nesting depth and is easy to mistype. The dig version is shorter, harder to get wrong, and communicates “I expect some of these keys might be absent” without the reader having to trace guard clauses:

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

dig also handles arrays mixed into the path. When a hash value is an array, you pass an integer index as the next argument and dig drills into the array element. This works for any depth of mixed hash and array nesting, which is common in JSON API responses where a key holds a list of objects:

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

This is the method to reach for when you are reading data that might be partially present. API responses, configuration trees, and form submissions all tend to have optional branches. dig lets you ask for a deeply nested value without turning the code into a ladder of nil checks.

There is a tradeoff. If a missing key should be treated as a bug, dig can make the failure too quiet. In that case, fetch is the better choice because it forces you to decide what should happen when the key is absent.

fetch for 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.

The rule of thumb is simple: use [] when absence is normal, use fetch when absence deserves attention. That distinction becomes important in code that calculates totals, loads settings, or relies on a value to exist before work can continue.

fetch is also a nice fit when you want a custom error message or a computed fallback. The block form keeps that behaviour next to the lookup itself, which is easier to scan than a separate conditional branch somewhere else in the method.

Hash.new with a block: lazy defaults

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

This is one of the most common hash tricks because it solves a subtle bug. A static default object is shared across all missing keys, which means one append can leak into every other lookup. The block form creates a new object for each key, so each bucket stays isolated.

# 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

The problem is that Hash.new([]) stores one shared array object as the default for every missing key. Every call to h[:missing] returns the exact same array. So a << append on one lookup contaminates every other lookup, and the hash itself stays empty because the default is returned without being stored. The block form sidesteps this: Hash.new { |hash, key| [] } creates a brand-new array each time a missing key is accessed, keeping each key’s data independent.

# 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. Instead of just returning a default, the block writes it into the hash with h[k] = default. After the first read, the key exists and holds its own value, so future lookups return the stored entry rather than calling the block again. This is the cleanest way to count occurrences or accumulate grouped values without pre-seeding keys:

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. The trick is Hash.new { |h, k| h[k] = Hash.new(&h.default_proc) }, which makes every nested level behave the same way as the top level. When you write tree[:users][:alice][:name], Ruby creates :users, then :alice inside it, then stores "Alice" under :name : all without explicit hash construction at each depth:

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" } } }

That recursive pattern is handy when you are building grouped counters, nested caches, or tree-shaped data. It removes the need to pre-seed every level before you can store a value.

merge for 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. The block receives the conflicting key, the old value, and the new value. You can merge arrays, sum numbers, pick the longer string, or keep whichever value is non-nil. This turns merge from a simple override into a custom resolution engine for each collision:

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. Before this, chaining merge calls was the only option: h1.merge(h2).merge(h3). The variadic form is cleaner when you have several overrides stacked up, such as system defaults, environment config, and user preferences. The last argument wins on conflict, just like the two-argument form:

h1.merge(h2, h3, h4)

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

merge is also where you decide how much conflict handling you want. The default behaviour is fine for configuration hashes, but a block gives you room to combine arrays, sum counters, or preserve the old value when the new one is not a real improvement.

slice for 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.

That behaviour makes slice predictable in controllers and service objects. You can ask for the fields you want and ignore the rest, which is often safer than filtering a hash manually with a custom loop. It also reads well when the permitted key list is short and obvious.

compact for 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 uses reject to filter out nil values. The reject form passes each key-value pair to a block and keeps only the entries where the block returns false. It works on any Ruby version and can filter on conditions beyond nil, such as empty strings or sentinel objects. But compact communicates the specific intent of “remove nils” more directly, which is why the dedicated method was added:

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

compact! is worth knowing about because it returns nil when nothing changes. That can be useful if you want to know whether cleanup happened, but it can also surprise you if you expect a hash back every time. When you just need a new hash without nil values, compact is the safer option.

Struct and OpenStruct for 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, so you can skip the OpenStruct.new constructor. Calling hash.to_ostruct wraps the hash in an OpenStruct that reads keys as method calls. This shortcut is handy when a hash arrives from an API or a YAML file and you want dot access without an extra assignment step. Just remember that to_ostruct only converts the top level; nested hashes stay as plain hashes:

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. Unlike OpenStruct, which uses method_missing for every attribute lookup, Struct generates real methods when you call Struct.new. This means attribute access runs at native method-call speed, and typos raise NoMethodError instead of silently returning nil. Use Struct when you know the field names ahead of time and want Ruby to enforce them:

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

The tradeoff is flexibility versus structure. OpenStruct is convenient when the keys are not known ahead of time, but it is slower and easier to misuse. Struct is a better fit when the shape is stable and you want Ruby to treat the record more like a plain object than an open-ended hash.

A real-world example

Putting several of these tricks together for 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.

This pattern scales well because each step has one job. The first line cleans up the raw input, the second line drops anything you do not trust, and the third line converts the remaining values into the type your code actually wants. That separation makes the flow easy to test and easy to extend.

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.

transform_keys and transform_values are not deep transforms. They only touch the top-level hash. If you need to normalize nested payloads, apply the method recursively or write a dedicated transformation step.

Struct is not a drop-in replacement for every hash. It is best when the fields are known and stable. If the shape varies from record to record, keep the data as a hash instead of forcing it into a rigid class.

Frequently asked questions

When should I use dig instead of fetch?

Use dig when a missing branch should return nil and the code can continue. Use fetch when missing data should be loud, or when you want a default value that is explicit at the call site.

Is compact better than reject { |_k, v| v.nil? }?

If you are on Ruby 3.0 or newer, yes. compact is clearer and communicates intent immediately. The reject form is still useful for older Ruby versions, or when you want to remove values based on a different condition.

Should I use OpenStruct or Struct?

Choose OpenStruct when the keys are not known ahead of time and convenience matters more than speed. Choose Struct when you want a compact record with a fixed set of fields and faster accessors.

Conclusion

These hash tricks help you choose the right level of strictness for each job. transform_keys reshapes incoming data, dig walks uncertain paths, fetch makes missing keys explicit, compact strips noise, and Struct or OpenStruct can turn a hash into something closer to a real object.

Hash code is easier to maintain when every method call tells the reader why the data is being handled that way. That is the real advantage of these tricks: less looping, fewer guard clauses, and a clearer path from raw input to usable Ruby objects.

See Also