← Back to blog

Hash#dig in Ruby: Safe Nested Access Without the nil Errors

· Lachlan Young

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.

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.