The Problem

Every Ruby project ends up reading configuration from a hash — YAML, JSON, ENV, you name it. The naive way is to write config[:timeout] || 30 or config.fetch(:timeout, 30). Both work until the value is legitimately false or nil and you actually want to keep that falsy value. The || operator treats any falsy value as missing, and fetch with a second argument evaluates the default even when the key exists, which can be wasteful or even dangerous if the default has side effects.

Enter Hash#fetch with a Block

Ruby’s Hash#fetch accepts a block that is executed only when the key is absent. This gives you lazy evaluation, preserves explicit nil/false values, and keeps the call site readable.

# Bad: default evaluated every time
timeout = config.fetch(:timeout, expensive_default())

# Good: block runs only when key missing
timeout = config.fetch(:timeout) { expensive_default() }

The block form also lets you raise a custom error or compute a context‑aware default without polluting the surrounding method.

Real‑World Example: Configuration Loading

Imagine a service that reads a YAML file, merges environment overrides, and then passes the resulting hash to various components. Some components need a timeout, others a retry count, and a few accept false to disable a feature entirely.

require 'yaml'

class Settings
  DEFAULTS = {
    http_timeout: 5,
    max_retries: 3,
    enable_cache: true,
    log_level: :info
  }.freeze

  def initialize(raw_hash)
    @config = DEFAULTS.merge(raw_hash.transform_keys(&:to_sym))
  end

  def http_timeout
    # Preserve explicit nil/false, compute default lazily
    @config.fetch(:http_timeout) { DEFAULTS[:http_timeout] }
  end

  def max_retries
    @config.fetch(:max_retries) { DEFAULTS[:max_retries] }
  end

  def enable_cache
    # Allows false to disable caching
    @config.fetch(:enable_cache) { DEFAULTS[:enable_cache] }
  end

  def log_level
    @config.fetch(:log_level) { DEFAULTS[:log_level] }
  end
end

# Usage
yaml = YAML.load_file('config.yml')
env_overrides = { http_timeout: ENV.fetch('HTTP_TIMEOUT', nil)&.to_i }.compact
settings = Settings.new(yaml.merge(env_overrides))

puts "Timeout: #{settings.http_timeout}"
puts "Cache enabled? #{settings.enable_cache}"

Notice how enable_cache can be set to false in the YAML and the accessor still returns false instead of falling back to true. The block is never executed because the key exists.

Why It Beats || and fetch(key, default)

  • Preserves falsy values. config[:flag] || true turns false into true. The block form returns the stored false.
  • Lazy evaluation. Expensive computations, network calls, or heavy object instantiation happen only when needed.
  • Clear intent. Reading fetch(:key) { default } signals “use this default only if the key is missing”.
  • Composable. You can chain fetch calls or delegate to other objects inside the block.

Pro tip: combine fetch with Hash#dig for nested structures: config.dig(:database, :pool) { 5 } (Ruby 3.2+ supports a block on dig as well).

Caveats and Tips

While the block form is powerful, keep a few things in mind:

  • Don’t mutate the original hash inside the block; it defeats the purpose of a pure default.
  • If you need the default to be a constant, define it once (as in DEFAULTS) and reference it in the block to avoid re‑creating objects.
  • For very hot paths, benchmark: a simple fetch(key, default) with a pre‑computed constant can be marginally faster because it avoids block allocation. In most apps the readability win outweighs the micro‑optimisation.

Wrap‑Up

Using Hash#fetch with a block is a small change that eliminates a whole class of bugs around falsy configuration values and unnecessary work. It expresses intent clearly, works nicely with immutable default objects, and fits naturally into the Ruby idiom of “ask for what you need, provide a fallback only when missing”. Next time you reach for || or a two‑argument fetch, pause and consider the block form — your future self (and your teammates) will thank you.