Hash Methods Worth Knowing — transform_keys, transform_values, filter, and merge with a Block
Hashes are the workhorse data structure of most Ruby applications — API responses, configuration objects, parameters, aggregations. Ruby’s Hash class has been steadily gaining methods that reduce the boilerplate of common transformations. transform_keys, transform_values, filter, and merge with a block solve problems developers routinely handle with manual each_with_object loops. Knowing these methods well means reaching for a loop less often and reading code that explains itself.
transform_keys and transform_values
Both methods return a new hash with every key (or value) transformed by the block, leaving the other side untouched.
Example:
config = { "host" => "localhost", "port" => "5432", "ssl" => "true" }
# Convert string keys to symbols
config.transform_keys(&:to_sym)
# => { host: "localhost", port: "5432", ssl: "true" }
# Upcase all keys
config.transform_keys(&:upcase)
# => { "HOST" => "localhost", "PORT" => "5432", "SSL" => "true" }
# Ruby 3.0+: transform_keys with a hash argument for selective rename
{ name: "Ada", email: "[email protected]", role: :admin }
.transform_keys(name: :full_name, email: :email_address)
# => { full_name: "Ada", email_address: "[email protected]", role: :admin }
Example:
# transform_values — coerce types in one pass
config.transform_values do |v|
case v
when /\A\d+\z/ then v.to_i
when "true" then true
when "false" then false
else v
end
end
# => { "host" => "localhost", "port" => 5432, "ssl" => true }
# Practical: normalize API response values
api_response = { score: "95.5", active: "1", name: "Ada" }
api_response.transform_values do |v|
Float(v, exception: false) || (v == "1") || v
end
# => { score: 95.5, active: true, name: "Ada" }
Both have bang variants (transform_keys!, transform_values!) that modify in place.
filter and reject — Selecting Key-Value Pairs
filter (aliased as select) keeps pairs where the block returns truthy. reject is the inverse.
Example:
scores = { alice: 88, bob: 72, carol: 95, dave: 61, eve: 90 }
# Keep passing scores
scores.filter { |_, v| v >= 75 }
# => { alice: 88, carol: 95, eve: 90 }
# Reject nil values — common cleanup pattern
data = { name: "Ada", email: nil, role: :admin, bio: nil }
data.reject { |_, v| v.nil? }
# => { name: "Ada", role: :admin }
# Filter by key pattern
config = { db_host: "localhost", db_port: 5432, redis_host: "localhost", redis_port: 6379 }
config.filter { |k, _| k.to_s.start_with?("db_") }
# => { db_host: "localhost", db_port: 5432 }
Example:
# Chaining filter with transform
permissions = { read: true, write: false, admin: true, delete: false }
permissions
.filter { |_, v| v }
.keys
# => [:read, :admin]
# Group by value
permissions.group_by { |_, v| v }.transform_values { |pairs| pairs.map(&:first) }
# => { true => [:read, :admin], false => [:write, :delete] }
merge With a Block — Conflict Resolution
merge without a block overwrites values for duplicate keys with the right-hand hash. With a block, you control exactly how conflicts are resolved.
Example:
defaults = { timeout: 30, retries: 3, ssl: false, pool_size: 5 }
overrides = { timeout: 60, ssl: true, debug: true }
# Without block — overrides win
defaults.merge(overrides)
# => { timeout: 60, retries: 3, ssl: true, pool_size: 5, debug: true }
# With block — custom conflict resolution
defaults.merge(overrides) do |key, old_val, new_val|
puts "Conflict on #{key}: #{old_val} → #{new_val}"
new_val # still use new value, but log it
end
Example:
# Sum conflicting numeric values
inventory_a = { apples: 10, oranges: 5, bananas: 8 }
inventory_b = { apples: 3, grapes: 12, bananas: 4 }
inventory_a.merge(inventory_b) { |_, a, b| a + b }
# => { apples: 13, oranges: 5, bananas: 12, grapes: 12 }
# Keep the maximum value
prices_a = { widget: 9.99, gadget: 24.99 }
prices_b = { widget: 11.99, gadget: 19.99, doohickey: 4.99 }
prices_a.merge(prices_b) { |_, a, b| [a, b].max }
# => { widget: 11.99, gadget: 24.99, doohickey: 4.99 }
# Keep arrays from both sides merged
tags_a = { ruby: ["fast", "dynamic"] }
tags_b = { ruby: ["readable"], python: ["popular"] }
tags_a.merge(tags_b) { |_, a, b| a + b }
# => { ruby: ["fast", "dynamic", "readable"], python: ["popular"] }
any?, all?, none?, count on Hashes
All Enumerable methods work on hashes — the block receives [key, value] pairs.
Example:
config = { host: "localhost", port: 5432, ssl: true, debug: false }
config.any? { |_, v| v == true } # => true (ssl is true)
config.all? { |_, v| v } # => false (debug is false)
config.none? { |k, _| k == :timeout } # => true (no :timeout key)
config.count { |_, v| v.is_a?(Integer) } # => 1 (only :port)
# min_by / max_by on hash values
scores = { alice: 88, bob: 72, carol: 95 }
scores.max_by { |_, v| v } # => [:carol, 95]
scores.min_by { |_, v| v } # => [:bob, 72]
scores.sort_by { |_, v| -v } # => [[:carol, 95], [:alice, 88], [:bob, 72]]
slice and except — Picking Specific Keys
slice returns a new hash containing only the specified keys. except (Ruby 3.0+) returns a hash with the specified keys removed.
Example:
user = { id: 1, name: "Ada", email: "[email protected]", password_digest: "...", role: :admin }
# Only the keys you want
user.slice(:name, :email, :role)
# => { name: "Ada", email: "[email protected]", role: :admin }
# All except sensitive keys
user.except(:password_digest, :id)
# => { name: "Ada", email: "[email protected]", role: :admin }
# Common pattern: safe serialization
def public_attributes(user)
user.slice(:name, :email, :role)
end
Example:
# Strong params-style filtering
def permitted_params(params)
params.slice(:title, :body, :published, :category_id)
.reject { |_, v| v.nil? }
.transform_keys(&:to_sym)
end
flat_map and Nested Hash Traversal
Example:
# Collect all values from a nested hash
settings = {
database: { host: "localhost", port: 5432 },
cache: { host: "redis", port: 6379 },
app: { host: "0.0.0.0", port: 3000 }
}
# All ports
settings.flat_map { |_, v| v[:port] }
# => [5432, 6379, 3000]
# All hosts
settings.filter_map { |section, v| [section, v[:host]] }.to_h
# => { database: "localhost", cache: "redis", app: "0.0.0.0" }
Pro-Tip: When normalizing API responses or params, chain
transform_keys,transform_values, andslicerather than writing a manual loop. A pipeline likeparams.transform_keys(&:to_sym).slice(:name, :email, :role).transform_values { |v| v.to_s.strip }is readable, composable, and doesn’t hide intent inside iteration. Each step does one thing. If a step gets complex enough to need explanation, extract it into a named method — but keep the pipeline structure visible.
Conclusion
transform_keys, transform_values, filter, reject, merge with a block, slice, and except cover the bulk of hash manipulation that used to require manual iteration. They’re not obscure — they’re in the standard library and well-documented. The developers who reach for them automatically write code that communicates intent at the expression level rather than at the loop level, and that composes cleanly when transformations need to be chained.
FAQs
Q1: When was except added to Hash?
Hash#except was added in Ruby 3.0. For older Ruby versions, use slice with the complement of keys you want, or reject { |k, _| [:key1, :key2].include?(k) }.
Q2: Does transform_keys handle nested hashes?
No — it only transforms the top-level keys. For recursive key transformation (common when normalizing API responses), use a recursive method or ActiveSupport::HashWithIndifferentAccess and deep_symbolize_keys in Rails.
Q3: What’s the difference between filter and select on a Hash?
They’re aliases — identical in behavior. select is the older name; filter was added in Ruby 2.6 to be consistent with Enumerable#filter. Use whichever reads more naturally to you; both are standard.
Q4: Does merge with a block handle all conflicting keys, or only specified ones?
The block is called for every key that exists in both the receiver and the argument hash. Keys that exist in only one hash are always included without calling the block.
Q5: Is transform_values available in older Rails via ActiveSupport?
transform_values was added to Ruby’s standard Hash in Ruby 2.4. Before that, Rails’ ActiveSupport provided it as an extension. If you’re on Ruby 2.4+, no gem required.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀