Hash#transform_keys
hash.transform_keys { |key| block } -> hash Hash#transform_keys creates a new hash by transforming each key using a block. Unlike Hash#each_with_object or manual iteration, this method provides a focused way to remap keys while keeping values intact.
It is especially useful when data arrives in one naming style and your Ruby code prefers another. For example, you might convert string keys into symbols, normalize API payloads, or build friendlier names for display.
syntax
hash.transform_keys { |key| block }
The block receives each key and should return the new key to use.
That simple contract makes the method easy to reason about. You keep the values exactly as they are, and only the key changes.
The examples below move from simple key conversions to more specific remapping patterns. They are arranged so you can see the common case first, then compare it with more customized transformations.
Basic usage
Simple key transformation
user = { name: "Alice", age: 30, city: "London" }
user.transform_keys { |key| key.to_s.upcase }
# => { "NAME" => "Alice", "AGE" => 30, "CITY" => "London" }
The block receives each key as it appears in the hash, so symbol keys arrive as symbols and string keys arrive as strings. You can apply any transformation that makes sense for the new naming convention: upcasing for constants, downcasing for normalization, or prefixing to namespace keys from different sources.
Symbol to string keys
config = { debug: true, timeout: 30, max_retries: 5 }
config.transform_keys(&:to_s)
# => { "debug" => true, "timeout" => 30, "max_retries" => 5 }
Passing a symbol with &: shorthand keeps the call site concise. This pattern is common when an API returns string-keyed data and your application code prefers symbol keys for readability and performance. The same technique works in the opposite direction when you need string keys for serialization, logging, or writing data to an external system that expects string-keyed hashes.
String to symbol keys
data = { "name" => "Bob", "age" => 25 }
data.transform_keys(&:to_sym)
# => { name: "Bob", age: 25 }
The shortcut &:to_sym works because to_sym is a method on both strings and symbols. For a symbol key, to_sym returns itself, so the transformation is safe even when you are not sure whether the input keys are strings or symbols.
Transforming with custom logic
Prefix or suffix keys
env = { DB_HOST: "localhost", DB_PORT: 5432 }
env.transform_keys { |k| "APP_#{k}" }
# => { "APP_DB_HOST" => "localhost", "APP_DB_PORT" => 5432 }
Prefixing keys is a straightforward way to namespace configuration values. Each key gets the same string prepended, so the block is a simple string concatenation. The original values pass through unchanged, and the new hash maps prefixed names to the same data.
Convert snake_case to camelCase
params = { user_name: "alice", user_email: "alice@example.com" }
params.transform_keys { |k| k.to_s.gsub(/_([a-z])/) { $1.upcase } }
# => { userName: "alice", userEmail: "alice@example.com" }
Converting naming conventions is one of the strongest use cases for transform_keys. The regex substitution targets underscores and capitalizes the following letter, turning snake_case into camelCase in a single pass. Since the block only touches keys, any deeply nested values like arrays or nested hashes stay exactly as they were.
Flatten nested keys
nested = { "user.name" => "Alice", "user.age" => 30 }
nested.transform_keys { |k| k.split(".") }
# => { ["user", "name"] => "Alice", ["user", "age"] => 30 }
Splitting dotted keys into arrays is useful when you need to access nested attributes by path. The resulting hash uses arrays as keys, which works fine in Ruby but is less common. If you prefer a nested hash structure instead, you would need a recursive approach instead of a single transform_keys call.
Comparison with related methods
transform_keys vs map
Using transform_keys is more idiomatic than using map to transform keys. Both versions can produce the same end result, but transform_keys says exactly what is changing, which makes the code easier to scan. The map approach needs an extra to_h call, adding a step that is less direct about its purpose.
hash = { a: 1, b: 2 }
# Less idiomatic
hash.map { |k, v| [k.to_s, v] }.to_h
# => { "a" => 1, "b" => 2 }
# More idiomatic
hash.transform_keys(&:to_s)
# => { "a" => 1, "b" => 2 }
The non-bang version creates a new hash, which is safer when the original needs to stay intact for later use. If you are building a pipeline of transformations and want to avoid mutating intermediate results, the non-bang form is the right default.
transform_keys vs transform_keys!
The bang version transform_keys! modifies the hash in place:
config = { debug: true, verbose: false }
config.transform_keys!(&:to_s)
# config is now { "debug" => true, "verbose" => false }
Use transform_keys when you want an immutable operation that returns a new hash. The distinction matters in code that shares a hash across several steps. If later code still needs the original keys, the non-bang version keeps the source object untouched. The bang version is a better fit when you are normalizing a single-use structure and do not care about preserving the original.
transform_keys vs transform_values
Transform keys operates on keys while keeping values the same:
scores = { alice: 95, bob: 82, carol: 78 }
scores.transform_keys(&:to_s)
# => { "alice" => 95, "bob" => 82, "carol" => 78 }
For transforming values instead, use transform_values. When you decide between the two, think about which part of the pair should change: transform_keys changes names, while transform_values changes data. Knowing which side needs updating helps you pick the right method without reading the block body.
scores.transform_values { |v| v + 10 }
# => { alice: 105, bob: 92, carol: 88 }
Chaining with other methods
transform_keys fits naturally into a pipeline because it returns a new hash that you can feed directly into the next method. You can normalize keys first, then filter with select, adjust values with transform_values, or combine with merge. Each step operates on the result of the previous one without mutating anything along the way.
Combine with select
data = { name: "Alice", age: 30, email: "alice@example.com", city: "London" }
data
.transform_keys(&:to_s)
.select { |k, _| k.start_with?("name", "email") }
# => { "name" => "Alice", "email" => "alice@example.com" }
Chaining transform_keys with select lets you normalize key types and then keep only the entries you care about. The order matters: convert keys first so your filter conditions can target the normalized names without guessing what case or type the original keys used.
Combine with transform_values
prices = { apple: 100, banana: 200, orange: 150 }
prices
.transform_keys(&:to_s)
.transform_values { |v| v * 1.1.round(2) }
# => { "apple" => 110.0, "banana" => 220.0, "orange" => 165.0 }
When you pair transform_keys with transform_values, you reshape both sides of each entry in one pipeline. This is cleaner than a single block that does double duty, because each transformation stays focused on one concern and the reader can see the flow at a glance.
Combine with merge
defaults = { debug: false, timeout: 30 }
defaults.transform_keys(&:to_s).merge({ "debug" => true })
# => { "debug" => true, "timeout" => 30 }
Merging after key transformation is a clean way to apply defaults or overrides to a normalized hash. The keys are converted first, so the merge hash can use the same key format without guessing whether the original used symbols or strings.
In-place modification with transform_keys!
If you need to modify the original hash instead of creating a new one, use transform_keys!:
settings = { env: "production", log_level: "info" }
settings.transform_keys!(&:to_s)
# settings is now { "env" => "production", "log_level" => "info" }
This is useful when working with configuration objects that you want to modify directly. Use the bang version when the data flow is simple and you do not need to preserve the original keys. That keeps the code compact, but it also makes the mutation explicit. If you are normalizing a payload before saving it or passing it deeper into the app, the bang version can be a good fit. When later code still needs the original keys, stay with the non-bang form.
Edge cases
Empty hash
{}.transform_keys { |k| k.to_s }
# => {}
An empty hash produces an empty hash, which is the expected no-op behavior. No block calls happen because there are no keys to transform, and the return value is a new empty hash, not the same object.
Duplicate transformed keys
When multiple keys transform to the same value, the last one wins:
hash = { a: 1, b: 2 }
hash.transform_keys { |k| :same_key }
# => { same_key: 2 }
This behavior matches how Ruby handles duplicate keys: the last assignment for a given key overwrites earlier ones. If your transformation could produce collisions, consider whether the order of entries in the original hash matters and whether losing earlier values is acceptable.
Block not provided
Without a block, transform_keys returns an enumerator:
hash = { a: 1, b: 2 }
hash.transform_keys.map { |k| k.to_s }
# => ["a", "b"]
Ruby version notes
- Ruby 2.5: Introduced
transform_keys - Ruby 2.5: Introduced
transform_keys! - Ruby 2.5: Added blockless form returning enumerator
See Also
- Hash#values — Get all hash values as an array
- Hash#keys — Get all keys as an array
- Hash#merge — Merge hashes together