transform_keys and transform_values in Ruby: What They Change and What They Quietly Skip
Most hash reshaping in Ruby used to look like each_with_object({}) with a block that rebuilt every pair by hand. Then Ruby 2.4 added transform_values, 2.5 added transform_keys, and a whole category of boilerplate disappeared. They are two of the most useful methods on Hash, and they are also two of the easiest to trust a little too much.
This post covers what they actually do, the argument forms most people never learn, and the four behaviours that have cost me time in real Rails code: they are shallow, they drop defaults, key collisions are silent, and nil does not survive to_s.
The basics
transform_keys builds a new hash with every key passed through a block. transform_values does the same for values. The other half of each pair is left alone.
user = { name: "Ada", role: "admin" }
user.transform_keys(&:to_s)
# => {"name"=>"Ada", "role"=>"admin"}
user.transform_values(&:upcase)
# => {:name=>"ADA", :role=>"ADMIN"}
Both return a new hash and leave the receiver untouched. That matters more than it sounds, because the old each_with_object version made it very easy to accidentally mutate the hash you were iterating, or to mutate the value objects themselves when you meant to replace them.
Compare the intent in these two versions:
# Before
prices_in_cents = prices.each_with_object({}) do |(sku, price), acc|
acc[sku] = (price * 100).round
end
# After
prices_in_cents = prices.transform_values { |price| (price * 100).round }
The second one tells you, in the method name, that the keys are not changing. A reviewer does not have to read the block to confirm it. That is the real value of these methods: they narrow what a line of code is allowed to do, which makes it easier to read and harder to get wrong.
The hash argument nobody uses
Since Ruby 3.0, transform_keys also accepts a hash that maps old keys to new ones. Keys not in the mapping are left as they are.
user.transform_keys(name: :full_name)
# => {:full_name=>"Ada", :role=>"admin"}
This is the form I reach for when renaming a couple of fields at a boundary, for example mapping an external API’s field names onto the attribute names a model expects. It reads like a lookup table, because it is one.
You can combine the mapping with a block. The mapping wins for keys it covers, and the block handles everything else:
user.transform_keys({ name: :full_name }, &:to_s)
# => {:full_name=>"Ada", "role"=>"admin"}
Notice the mixed key types in that result. The mapped key stays a symbol because the block never ran on it. That is correct behaviour and it is also exactly the kind of output that fails an equality check later, so if you want uniform keys, make the mapping produce the final type directly.
Gotcha one: they are shallow
This is the one that catches everyone. transform_keys only touches the top level.
payload = { user: { name: "Ada", tags: [{ id: 1 }] } }
payload.transform_keys(&:to_s)
# => {"user"=>{:name=>"Ada", :tags=>[{:id=>1}]}}
The outer key became a string. Everything inside kept its symbol keys. If you then compare this against a parsed JSON body, which has string keys all the way down, the assertion fails on a structure that looks almost right, and the failure output buries the one level that did not change.
In Rails, use the deep variants from ActiveSupport: deep_transform_keys, deep_stringify_keys, and deep_symbolize_keys. They walk into nested hashes and into arrays of hashes.
payload.deep_transform_keys(&:to_s)
# => {"user"=>{"name"=>"Ada", "tags"=>[{"id"=>1}]}}
Outside Rails, a small recursive helper does the job and is worth keeping somewhere in your project rather than rewriting each time:
def deep_transform_keys(obj, &block)
case obj
when Hash
obj.each_with_object({}) do |(key, value), acc|
acc[block.call(key)] = deep_transform_keys(value, &block)
end
when Array
obj.map { |element| deep_transform_keys(element, &block) }
else
obj
end
end
There is no built-in deep_transform_values in plain Ruby either. ActiveSupport added one in Rails 6.0. If you are on anything older, the same pattern works with the block applied to leaf values instead of keys.
Gotcha two: defaults are dropped
If your hash has a default value or default block, the result of either transform does not.
counts = Hash.new(0)
counts[:apples] = 3
doubled = counts.transform_values { |n| n * 2 }
counts[:pears] # => 0
doubled[:pears] # => nil
Code that relied on the default, usually something like doubled[key] += 1, now raises NoMethodError on nil. The fix is to set the default again on the result, or to use fetch with an explicit fallback when you read. I prefer the second, because it keeps the fallback visible at the point where it matters instead of hiding it on a hash that was built three methods away.
The same is true of select, reject, map followed by to_h, and most other methods that return a new hash. It is not specific to transforms, it is just most surprising here because the method name suggests you get the same hash with small changes.
Gotcha three: key collisions are silent
When two keys transform to the same value, the later one wins and the earlier one disappears. No warning, no error.
mixed = { "id" => 1, id: 2 }
mixed.transform_keys(&:to_s)
# => {"id"=>2}
This shows up in real code more often than you would expect. A hash assembled from params and defaults, a merge of two sources where one used strings and the other used symbols, or a case-insensitive normalization like transform_keys(&:downcase) on headers. The resulting hash is one key shorter than the input and nothing tells you.
If collisions are possible, check the size before and after in the code path where it matters:
normalized = headers.transform_keys(&:downcase)
raise ArgumentError, "duplicate header after normalization" if normalized.size != headers.size
That is one line, and it turns a silent data loss into a loud failure at the point where you can still see why.
Gotcha four: nil becomes an empty string
transform_values(&:to_s) is a common way to prepare a hash for something that wants strings, like a CSV row or a query string. It also turns every nil into "".
{ name: "Ada", phone: nil }.transform_values(&:to_s)
# => {:name=>"Ada", :phone=>""}
Downstream, phone.present? still behaves, but phone.nil? does not, and a test that expects nil now fails on a value that prints as nothing. If you want to preserve nils, say so:
record.transform_values { |value| value&.to_s }
The safe navigation operator keeps nil as nil and converts everything else. It is a small change, and it is the difference between a serialized record that round trips and one that quietly does not.
The bang versions
transform_keys! and transform_values! modify the hash in place and return it. I use them rarely. In place mutation of a hash that came from somewhere else is a good way to create a bug in code you did not write, particularly with frozen constants and memoized values that other callers share. The allocation you save is almost never worth it. If you are reshaping data you built yourself inside the same method, the bang version is fine, and otherwise I default to the non-bang form.
When to use something else
If you need to change keys and values together based on each other, neither method fits. Use to_h with a block, which has been available since Ruby 2.6:
prices.to_h { |sku, price| [sku.to_s.upcase, (price * 100).round] }
And if you need to drop pairs as well as reshape them, filter_map followed by to_h, or a plain each_with_object, is clearer than chaining a transform with select.
The short version
Reach for transform_keys and transform_values whenever only one side of the pair is changing, because the method name documents that for you. Remember that they are shallow, so use the deep_ variants for nested data. Reapply defaults or use fetch on the result. Check sizes when normalizing keys that might collide. Use &.to_s when nils need to stay nils.
Most of these gotchas end in the same place: two hashes that look the same and are not, usually because a key changed type at one level but not another, or a nil turned into an empty string. Try RubyHash to paste both sides and see exactly which key or value changed type, without scanning nested output by eye.
Enjoyed this post?
Subscribe to get notified when we publish more Ruby and Rails content.