🔙 to the list of posts

Hash#fetch

After discovering the versatility of Ruby’s Hash#fetch method while watching one of Avdi Grimm’s excellent Ruby Tapas screencasts, I’ve found myself using it more and more. In this post I’ll share what I’ve learnt.

Just like Hash#[], fetch returns the value corresponding to a given key. What sets these methods apart is how they handle keys that can’t be found in the hash: while [] returns the hash’s default value (nil by default), fetch will raise a KeyError.

This might seem harsh but it can be extremely helpful when trying to figure out why something you expect to be there isn’t.

For example, imagine you’re fetching data from a remote weather API. When parsed into a hash the data looks something like this:

response = {
  temp: -3.72,
  humidity: 68,
  weather: {
    id: 80,
    description: 'broken clouds'
  }
}

Your application might use this data to construct a Weather object like so:

weather = Weather.new

weather.temperature = response[:temp]
weather.description = response[:weather][:description]

Now, let’s say that the API responses are sometimes missing the weather description. This wouldn’t break the code above but in another part of your application, where the weather information is logged, an exception is raised.

logger.info "It is #{weather.temperature}°C and "\
            "#{weather.description.capitalize} in Stockholm"
#=> NoMethodError: undefined method `capitalize' for nil:NilClass

The code above assumes that the weather object has a description and it’s not clear from the error message why it’s missing.

If the code responsible for constructing the weather object had been using fetch, the problem would’ve been detected where it first arose:

weather.description = response.fetch(:weather).fetch(:description)
#=> KeyError: key not found: :description

It’s now clear from the error message that the response is missing the weather description. This doesn’t solve the problem but it turns out we can use fetch for that too.

When passed a block in addition to a key that can’t be found, instead of raising a KeyError, fetch will call the block and return whatever it returns:

weather.description = response.fetch(:weather).fetch(:description) { 'unknown' }

weather.description #=> "unknown"

This behavior is similar to using [] together with the || operator but it isn’t quite the same:

options = {
  false: false,
  nil: nil
}

options[:missing] || 'default'           #=> "default"
options[:false]   || 'default'           #=> "default"
options[:nil]     || 'default'           #=> "default"

options.fetch(:missing) { 'default' }    #=> "default"
options.fetch(:false)   { 'default' }    #=> false
options.fetch(:nil)     { 'default' }    #=> nil

fetch only returns the default value if the key can’t be found, not when the corresponding value is falsey. That might not seem so different but it has some interesting consequences. For example, it enables you to write code like this:

def inform(name, message, options = {})
  greeting = options.fetch(:greeting) { 'Hey' }

  print "#{greeting} " if greeting
  puts "#{name}, #{message}"
end

inform 'Alice', 'your pancakes are ready!'
#=> Hey Alice, your pancakes are ready!

inform 'Alice', 'your pancakes are ready!', greeting: 'Howdy'
#=> Howdy Alice, your pancakes are ready!

inform 'Alice', 'your pancakes are ready!', greeting: false
#=> Alice, your pancakes are ready!

Another interesting feature of fetch is that when a key can’t be found and it calls the given block, it will pass the missing key to the block.

{}.fetch(:missing) { |key| "The key '#{key}' is missing!" }
#=> "The key 'missing' is missing!"

This feature combined with the fact that lambdas and procs can be converted to blocks using the & operator makes it possible to reuse the default behavior for multiple calls to fetch:

cache = { name: 'Bob' }

fallback = ->(key) { cache[key] = SlowStorage.get(key) }

name  = cache.fetch(:name, &fallback)
email = cache.fetch(:email, &fallback)
phone = cache.fetch(:phone, &fallback)

Instead of passing a block to fetch in order to specify a default value, one can also pass a second parameter like so:

{}.fetch(:missing, 'default')    #=> "default"

This seems to work exactly the same but there is a catch. Let me illustrate this with an example:

require 'benchmark'

def expensive_calculation
  sleep 1
end

hash = { present: 'present' }

Benchmark.measure {
  hash.fetch(:missing, expensive_calculation)
}.real    #=> 1.0053069996647537
Benchmark.measure {
  hash.fetch(:present, expensive_calculation)
}.real    #=> 1.0013770000077784
Benchmark.measure {
  hash.fetch(:missing) { expensive_calculation }
}.real    #=> 1.0051070000045002
Benchmark.measure {
  hash.fetch(:present) { expensive_calculation }
}.real    #=> 0.0000109998509287

Can you spot the difference? When specifying the default value using the second parameter the expensive calculation is done even when the key is present in the hash!

This happens because Ruby evaluates a method’s arguments before calling the method but doesn’t evaluate blocks until they are yielded to.

For this reason, I default to specifying default values using a block when calling fetch.

To summarize, Hash#fetch is exceptionally useful and can make it easier to discover data inconsistency issues and allows for great flexibility in handling missing data and providing default values.

About the author

Hey there, I’m Calle! I’m a programmer based in Stockholm, Sweden.

If you liked this post, you might also like one of the other posts I’ve written. Or, if you’re in the mood for watching something, here are a few talks I’ve given. 🍿