← Back to blog

deep_merge in Ruby: Why merge Clobbers Your Nested Hashes

· Lachlan Young

Here is a bug I have watched people ship more than once. You have a hash of defaults, you merge in some overrides, and one of the values quietly disappears.

defaults = {
  timeout: 30,
  retries: 3,
  headers: { "Accept" => "application/json", "User-Agent" => "MyApp" }
}

overrides = {
  headers: { "Authorization" => "Bearer abc123" }
}

config = defaults.merge(overrides)
# => {
#      timeout: 30,
#      retries: 3,
#      headers: { "Authorization" => "Bearer abc123" }
#    }

Look at headers. You wanted to add an Authorization header. Instead you lost Accept and User-Agent entirely. The request goes out with the wrong content type, the server does something unexpected, and now you are staring at a failing test wondering where your headers went.

Nothing is broken. merge did exactly what it promises. The problem is that what it promises is not what most people assume.

merge only looks at the top level

Hash#merge combines two hashes one key at a time. When the same key exists in both, the value from the argument wins. That is the whole rule. It does not care that the value is itself a hash, and it makes no attempt to combine those inner hashes. The right-hand value replaces the left-hand value wholesale.

{ a: { x: 1 } }.merge(a: { y: 2 })
# => { a: { y: 2 } }

The inner { x: 1 } is gone. merge saw two values for key a, picked the second one, and moved on. This is a shallow merge, and it is the correct behaviour for a lot of cases. The trouble starts the moment your hashes are nested, which in real applications is most of the time: config trees, JSON payloads, option hashes passed down through several methods, serializer output. All of it is nested, and all of it is vulnerable to this.

You can steer merge with a block, which runs on every conflicting key and lets you decide the winner:

defaults.merge(overrides) do |key, old_val, new_val|
  old_val.merge(new_val)
end

That works for exactly one level of nesting. Go two levels deep and you are back to the same clobbering, one hash further down. Writing a block that recurses correctly, handles the case where only one side is a hash, and does not blow up on non-hash values is precisely the wheel you should not be reinventing.

deep_merge, the Rails way

If you are in Rails, or anywhere ActiveSupport is loaded, the fix is already there. deep_merge recurses. When both sides of a key are hashes, it merges them instead of replacing one with the other.

defaults.deep_merge(overrides)
# => {
#      timeout: 30,
#      retries: 3,
#      headers: {
#        "Accept" => "application/json",
#        "User-Agent" => "MyApp",
#        "Authorization" => "Bearer abc123"
#      }
#    }

Now the headers hash keeps everything. Accept and User-Agent survive, Authorization is added, and any header present in both would take its value from the override. There is a bang version, deep_merge!, that mutates the receiver in place, which is handy when you are building up a config hash and do not want a new object on every step.

The rule for leaf values is the same as plain merge: the right-hand side wins. Recursion only kicks in when both values are themselves hashes. So deep_merge changes how nesting is handled without changing how actual value conflicts resolve, which is exactly what you want.

When you are not in Rails

Plain Ruby has no deep_merge, so if you are writing a gem or a script without ActiveSupport, you write it yourself. It is short:

def deep_merge(base, other)
  base.merge(other) do |_key, base_val, other_val|
    if base_val.is_a?(Hash) && other_val.is_a?(Hash)
      deep_merge(base_val, other_val)
    else
      other_val
    end
  end
end

The block only recurses when both values are hashes. If either side is a scalar, an array, or nil, the other value wins, matching the ActiveSupport behaviour. Ten lines, no dependency, and now you understand exactly what it does, which is more than most people can say about the framework version they call every day.

The array question nobody agrees on

There is one decision deep_merge makes that surprises people, and it is worth knowing before it bites you. Arrays are treated as leaf values, not as things to combine. When a key holds an array on both sides, the right-hand array replaces the left one. It does not concatenate, and it does not merge element by element.

{ tags: ["a", "b"] }.deep_merge(tags: ["c"])
# => { tags: ["c"] }

If you expected ["a", "b", "c"], you now have a bug, and it is the kind that slips through code review because the line reads like it should append. There is no universally right answer here. Sometimes replacing is what you want, sometimes concatenating is, sometimes you want a union with duplicates removed. Because there is no single correct behaviour, deep_merge picks the simplest one and stays out of your way. If you need arrays combined, do it explicitly with your own merge block rather than hoping the default matches your intent.

Where this actually bites in Rails

The most common place I see this is merging params or options into a settings hash. You pull a defaults hash from a YAML file or a constant, merge in per-request overrides, and one nested section silently reverts to just the overrides. Feature flags, notification preferences, and API client options are all classic victims, because they are naturally two or three levels deep and the override usually only touches one leaf.

Test fixtures are the other big one. You define a base attributes hash for a factory, then merge in the fields a particular test cares about, and a nested metadata or settings key loses everything you did not restate. The test passes for the wrong reason, or fails on a diff that makes no sense until you realise half the nested hash evaporated.

When a hash comparison in a test fails and the two sides look almost identical, a shallow merge upstream is one of the first things worth suspecting. Reach for deep_merge when you meant to combine nested data, keep plain merge when you genuinely want the right side to replace the left, and know which one you are calling. The bug is never that the method misbehaved. It is that shallow and deep look the same at the call site until the data is nested enough to tell them apart.

Debugging two nested hashes that should match but do not? Paste them into RubyHash for a clean side-by-side diff that shows exactly which key changed.

Enjoyed this post?

Subscribe to get notified when we publish more Ruby and Rails content.