Hash#compact in Ruby: Removing nil Values, and the Cases It Does Not Cover
The short version
Hash#compact returns a copy of the hash with every key whose value is nil removed. That is all it does. It does not look inside nested hashes, it does not touch false, "", [] or {}, and it leaves the original hash alone. Most of the bugs I have seen around it come from expecting it to do one of those other things.
attrs = { name: "Ada", nickname: nil, admin: false, bio: "" }
attrs.compact
# => { name: "Ada", admin: false, bio: "" }
Why nil values show up in the first place
In a Rails app, nil values in a hash usually come from one of three places: optional form fields that were not filled in, a service object that builds a payload from several sources, or a model’s attributes hash where unset columns are nil.
user.attributes.slice("name", "nickname", "bio")
# => { "name" => "Ada", "nickname" => nil, "bio" => nil }
Sometimes you want those keys, because nil is information (“this attribute exists and is empty”). Sometimes you want them gone, because you are about to hand the hash to something that treats a present key as an instruction. That second case is where compact earns its place.
The classic use: not overwriting with nil
Say you accept optional updates and only want to change what the caller actually supplied:
def update_profile(user, name: nil, nickname: nil, bio: nil)
changes = { name: name, nickname: nickname, bio: bio }.compact
user.update!(changes)
end
update_profile(user, nickname: "Countess")
# only nickname is written
Without compact, every call would blank out the fields the caller did not mention. This works, with one honest limitation: you can no longer distinguish “leave this alone” from “set this to nil”. If a caller needs to clear a field, nil cannot mean both things. In that case use a sentinel such as UNSET = Object.new for the default, or split the API into separate calls. I prefer the sentinel when the method is a real public boundary, and plain compact when clearing a field is not a supported operation.
compact versus compact!
compact! mutates the receiver, and it has a quirk that catches people: it returns nil when nothing was removed.
h = { a: 1, b: nil }
h.compact! # => { a: 1 }
h.compact! # => nil, nothing left to remove
That return value makes compact! unsafe at the end of a method or in a chain, because the method silently returns nil on the second call. Use compact unless you have a measured reason to avoid the allocation. Rails code almost never has that reason.
It is shallow
This is the surprise that shows up most often once the payloads get realistic:
payload = {
user: { name: "Ada", nickname: nil },
tags: nil,
address: { street: nil, city: nil }
}
payload.compact
# => { user: { name: "Ada", nickname: nil }, address: { street: nil, city: nil } }
Only the top level lost its nil. The nested nickname, street and city are all still there, and address is now a hash full of nothing. If you want it all the way down, write a small recursive helper rather than reaching for a gem:
def deep_compact(value)
case value
when Hash
value.each_with_object({}) do |(key, val), result|
cleaned = deep_compact(val)
result[key] = cleaned unless cleaned.nil?
end
when Array
value.map { |item| deep_compact(item) }.compact
else
value
end
end
deep_compact(payload)
# => { user: { name: "Ada" }, address: {} }
Notice address survived as an empty hash. Whether that is right depends on the consumer. If an empty object is as meaningless as a missing one, prune empties in the same pass. That is a decision about your data, not something compact can make for you, which is why I would not hide it inside a clever one-liner.
false, empty strings and compact_blank
compact only removes nil. People coming from other languages sometimes expect it to drop falsy values:
{ active: false, count: 0, note: "" }.compact
# => { active: false, count: 0, note: "" }
Nothing is removed, and that is the correct behaviour. false is a real answer to a yes or no question, and 0 is a real count. Rails adds compact_blank (since Rails 6.1) for the broader case, and it removes anything blank? returns true for:
{ active: false, count: 0, note: "", tags: [], owner: nil }.compact_blank
# => { count: 0 }
Look at that result carefully. active: false is gone, because false.blank? is true. That is exactly what you do not want for a boolean flag. For parameters coming from an HTML form, where blank inputs arrive as empty strings, compact_blank is usually what you want. For a hash that carries real booleans, compact is the safer default. It is an easy bug to ship: a compact_blank quietly drops subscribed: false, and it only shows up when a user tries to unsubscribe.
compact changes equality
Two hashes are only equal if they have the same keys, so a key holding nil is not the same as a missing key:
{ a: 1, b: nil } == { a: 1 } # => false
{ a: 1, b: nil }.compact == { a: 1 } # => true
This matters in tests more than anywhere else. A test that builds an expected hash with { a: 1 } will fail against an actual hash of { a: 1, b: nil }, and the failure message can be tedious to read when the hashes are large. Before you decide the code is wrong, decide which behaviour you actually want to pin down. If a nil key and a missing key are genuinely equivalent for your API, normalise before asserting:
assert_equal expected.compact, actual.compact
If they are not equivalent, for example a JSON API where "nickname": null means “explicitly cleared”, then compacting in the test would hide a real regression. Be deliberate. The same goes for serialisation: { nickname: nil }.to_json produces {"nickname":null}, and the receiver may treat that differently from the key being absent.
A few related idioms
compact is shorthand for rejecting nil values, so the longer form is occasionally useful when your condition is not exactly nil:
hash.reject { |_key, value| value.nil? } # same as compact
hash.reject { |_key, value| value.nil? || value == "N/A" }
hash.select { |_key, value| value } # drops nil AND false
That last line is the one to be careful with. select { |_, v| v } removes false along with nil, which is the same trap as compact_blank.
Array has its own compact, and the same logic applies there: it removes nil elements only. When you build a hash from a list with filter_map, you often do not need compact afterwards, because filter_map already drops falsy results:
ids = %w[1 2 x 4]
ids.filter_map { |s| Integer(s, exception: false) }
# => [1, 2, 4]
What I actually do
Use compact at the boundary where a hash becomes an instruction: building attributes for update, building query parameters, or building a request body where null would be harmful. Use compact_blank only for form-style input where empty strings mean “nothing here” and no value is a real boolean. Write a named recursive helper when the nesting is real. And in tests, decide explicitly whether nil and missing are the same thing before you let compact paper over the difference.
Comparing two hashes where one has a nil key and the other does not? Paste both into Try RubyHash and see the difference highlighted in your browser, with nothing uploaded.
Enjoyed this post?
Subscribe to get notified when we publish more Ruby and Rails content.