Hash#dig in Ruby: Safe Nested Access Without the nil Errors
The error message is familiar to anyone who has consumed a third party API:
NoMethodError: undefined method `[]' for nil:NilClass
It comes from a line like this one:
city = response[:user][:address][:city]
If response[:user] exists but has no :address, the second [] returns nil, and the third [] is called on nil. The crash is not on the missing key, it is on the next lookup after it. That distinction matters, because it tells you what the fix has to do: stop the chain as soon as something is nil.
What dig does
Hash#dig takes a path of keys and walks it one step at a time. If any step returns nil, it stops and returns nil instead of raising:
response = { user: { name: "Alice" } }
response.dig(:user, :address, :city)
# => nil
response.dig(:user, :name)
# => "Alice"
The same line written without dig and without a crash looks like this:
response[:user] && response[:user][:address] && response[:user][:address][:city]
Or, if you have written Ruby for a few years, with the safe navigation operator:
response[:user]&.[](:address)&.[](:city)
Nobody enjoys reading that second one. dig is the version that says what it means.
It works across arrays too
dig is defined on Hash, Array, and Struct, and it can mix them in one call. That is where it earns its place, because real payloads are rarely hashes all the way down:
order = {
id: 42,
items: [
{ sku: "A-100", price: { amount: 1200, currency: "AUD" } },
{ sku: "B-200", price: { amount: 800, currency: "AUD" } }
]
}
order.dig(:items, 0, :price, :amount)
# => 1200
order.dig(:items, 5, :price, :amount)
# => nil
Indexing past the end of an array returns nil, so the out of range case is handled the same way as a missing key. No rescue, no length check.
The key type trap
dig does not forgive you for using the wrong kind of key. This is the single most common reason a dig call returns nil when you are sure the data is there:
payload = JSON.parse('{"user": {"name": "Alice"}}')
payload.dig(:user, :name)
# => nil
payload.dig("user", "name")
# => "Alice"
JSON.parse returns string keys unless you pass symbolize_names: true. A missing key and a key of the wrong type look identical to dig, so a silent nil can hide a bug for a long time. If you parse JSON yourself, decide on one key type at the boundary and stay with it. In a Rails app, ActiveSupport::HashWithIndifferentAccess solves this for dig as well, since it converts the keys you pass in, but it comes with its own pitfalls that deserve their own treatment.
When dig is the wrong tool
dig is good at being forgiving, and that is exactly the problem when you are not supposed to be forgiving. Consider a webhook handler:
amount = event.dig("data", "object", "amount")
charge_customer(amount)
If Stripe, or whoever sends you the event, changes the payload shape, amount is nil, and you find out when charge_customer blows up three methods later, or worse, when it does not blow up and charges nothing. The error is far from the cause.
When a value is required, say so. Hash#fetch raises a KeyError that names the missing key, and you can chain it:
amount = event.fetch("data").fetch("object").fetch("amount")
That is noisy, so many teams wrap the pattern in a small helper:
def required_dig(hash, *path)
path.reduce(hash) { |memo, key| memo.fetch(key) }
end
required_dig(event, "data", "object", "amount")
# => KeyError: key not found: "amount"
My rule of thumb: use dig for data that is genuinely optional, such as an address a user may not have filled in, and use fetch for data your code cannot continue without. A nil that should never have been nil is a bug you want to see immediately, at the line that caused it.
A related trap: dig on non hash values
If an intermediate value is not something that responds to dig, you get a TypeError, not nil:
data = { user: "Alice" }
data.dig(:user, :name)
# => TypeError: String does not have #dig method
This one surprises people, because the happy path of dig trains you to expect nil for anything unexpected. It makes sense once you think about it: a missing key is a normal condition, but a String sitting where a Hash should be means the shape of your data is wrong, and Ruby tells you so. In practice it shows up when an API returns "" for an empty object, or false for a missing flag in a nested structure.
If you need to defend against it, check the type at the point where you do not trust the source:
user = data[:user]
name = user.is_a?(Hash) ? user[:name] : nil
It is not pretty, but untrusted data is not pretty. Do it once at the edge of your system and keep the rest of the code simple.
dig with default values
dig returns nil when something is missing, and often you want a fallback instead. The idiomatic answer is ||:
currency = order.dig(:items, 0, :price, :currency) || "AUD"
Be careful when the legitimate value can be false. false || "AUD" gives you "AUD", which is wrong if the flag was explicitly turned off:
settings = { notifications: { email: false } }
settings.dig(:notifications, :email) || true
# => true, but the user turned it off
For booleans, check for nil explicitly:
value = settings.dig(:notifications, :email)
value = true if value.nil?
This is the same class of bug as using ||= on a boolean, and it has caused real incidents in preference and feature flag code.
Hash#dig and ActiveRecord
You will see dig on jsonb columns constantly. A jsonb attribute comes back as a Hash with string keys, so the key type trap applies in full:
class Account < ApplicationRecord
# settings is a jsonb column
end
account.settings.dig("billing", "address", "postcode")
Two things worth knowing. First, a jsonb column can be nil itself if you did not give it a default, so account.settings.dig(...) raises on nil. Give the column default: {} and null: false in the migration, which removes a whole category of guard clauses. Second, if you are reaching three levels deep into a jsonb column all over your codebase, that is a hint the data wants to be a model or a store accessor, not a pile of dig calls.
Testing code that digs
Because dig swallows missing keys, the interesting test cases are the ones where the shape is slightly wrong, not the happy path. For each dig call on external data, I like to write three tests: the full payload, the payload with the leaf missing, and the payload with an intermediate level missing. If the third one does not do what you expect, you have found the bug before production did.
def test_postcode_is_nil_when_address_missing
account = Account.new(settings: { "billing" => {} })
assert_nil account.postcode
end
When one of these comparisons fails on a large nested hash, the diff Minitest prints is a single wrapped line, and finding the one differing leaf by eye is slow. The minitest-hashdiff gem formats that output so the changed path is obvious.
The short version
Use dig when a missing branch is a normal outcome, and expect nil. Use fetch when a missing branch means something is broken, and expect an exception that names the key. Keep your key types consistent, remember that dig raises a TypeError on a non hash value in the middle of the path, and watch for false when you add a fallback with ||. A forgiving method is only a good idea where forgiveness is the behaviour you actually want.
Enjoyed this post?
Subscribe to get notified when we publish more Ruby and Rails content.