skip to content

Hashes & Defaults

Ruby hashes keep insertion order, and fetch raises where [] falls back to a default value or default proc. Interviewers probe the Hash.new([]) shared-object trap and mutated keys.

on this pageshow

explore

questions

6

In Ruby, how does Hash#fetch differ from Hash#[] when a key is missing, and what do fetch's default argument and block do?

level: juniorimportance: must knowfreq 78%

answer

  1. [] consults the hash default
  2. fetch raises KeyError
  3. KeyError#key and #receiver
  4. fetch ignores Hash.new defaults
  5. block beats default, with a warning

basics

~20 s

Hash#[] returns the hash's default, nil unless one was set, for a missing key. Hash#fetch raises KeyError instead, or returns its second argument or its block's value, and it ignores the hash's default and default proc.

solid answer

~40 s

`review[:rating]` returns `nil` for a missing key, or the hash's default if `Hash.new` set one, so it cannot tell a missing key from one stored as `nil`. `review.fetch(:rating)` raises `KeyError` with `key not found: :rating`, and the exception's `key` and `receiver` say what was missing where. `fetch(:rating, 0)` returns `0` on a miss, and `fetch(:rating) { |key| ... }` runs the block only on a miss, which matters when the fallback is expensive because a default argument is evaluated on every call. Passing both warns `block supersedes default value argument` and uses the block. `fetch` never consults the hash's default or default proc. Use `fetch` for required keys such as configuration, and `[]` or `dig` for optional ones.

code

ruby · 18 lines
ruby
review = {title: "Dune", rating: 5, body: nil}

review[:body]              # => nil (stored)
review[:author]            # => nil (missing)
review.key?(:author)       # => false

review.fetch(:rating)      # => 5
review.fetch(:stars, 0)    # => 0
review.fetch(:stars) { |k| "no #{k}" } # => "no stars"

begin
  review.fetch(:author)
rescue KeyError => e
  e.message  # => "key not found: :author"
  e.key      # => :author
end

Hash.new(0).fetch(:views, 7) # => 7, the hash default is ignored

go deeper

for a junior

Recall that [] returns nil or the hash default on a miss while fetch raises KeyError, and that fetch takes a fallback argument or block.

for a middle

Explain that fetch ignores Hash.new defaults, that its default argument is evaluated eagerly while the block is lazy, and how key? separates missing from nil.

for a senior

Use fetch at trust boundaries so bad payloads and missing settings fail where they are read, and report KeyError#key rather than parsing messages.

for a principal

Set a team convention for required versus optional hash reads so failures surface early without scattering nil checks through the codebase.

## Two ways to read a key A Ruby `Hash` offers two everyday readers with opposite attitudes to a missing key: - **`Hash#[]`** is lenient. A missing key returns the hash's **default**, which is `nil` unless the hash was built with `Hash.new(value)` or a default block. - **`Hash#fetch`** is strict. A missing key raises **`KeyError`** unless the call itself supplies a fallback. Take a book review parsed from a form: `review = {title: "Dune", rating: 5, body: nil}`. ## [] and the ambiguity of nil `review[:body]` and `review[:author]` both return `nil`, but only one key exists. When `nil` is a legitimate stored value, `[]` cannot distinguish "absent" from "present and empty". `review.key?(:body)` (also spelled `include?`, `member?`, `has_key?`) answers the presence question directly. `[]` also honours the hash default: on `counts = Hash.new(0)`, `counts[:missing]` returns `0`. The same is true of `dig` and `values_at`. ## fetch and KeyError `review.fetch(:author)` raises `KeyError` with the message `key not found: :author`. The exception carries two readers: - **`KeyError#key`** returns the missing key, `:author`. - **`KeyError#receiver`** returns the hash that was searched. These make error reports precise without parsing the message. Crucially, `fetch` **ignores the hash's default and default proc**: `Hash.new(0).fetch(:missing)` still raises. The Ruby documentation says so explicitly, and it catches people who assume a counting hash is safe to `fetch` from. ## Typos surface with fetch A misspelled key is the most common reason a hash read goes wrong. `review[:ratng]` quietly returns `nil`, and the bug shows up far away as a wrong total or a `NoMethodError` on `nil`. `review.fetch(:ratng)` fails on the spot, and because Ruby loads the bundled `did_you_mean` library by default, the error's full message adds a suggestion such as `Did you mean? :rating` when a close key exists. Rescue `KeyError` specifically where a missing key is expected, rather than a broad `rescue`, so real typos still reach your error reports. ## Fallbacks supplied at the call site | Call | Key present | Key missing | |---|---|---| | `h[k]` | value | hash default, usually `nil` | | `h.fetch(k)` | value | raises `KeyError` | | `h.fetch(k, fallback)` | value | `fallback` | | `h.fetch(k) { \|key\| ... }` | value, block not run | block's return value | Two details matter in practice: 1. **The default argument is always evaluated.** `h.fetch(k, load_from_disk)` calls `load_from_disk` before `fetch` runs, even when the key exists. The block form runs only on a miss. 2. **Passing both** a default and a block prints the warning `block supersedes default value argument`, and the block wins. `fetch_values(:a, :b)` is the multi-key strict reader: it raises `KeyError` for any missing key, or calls its block for each. `values_at(:a, :b)` is the lenient one and returns defaults. ## Nested data: dig versus chained fetch For nested data, such as a review with an `author` sub-hash: - **`review.dig(:author, :name)`** returns `nil` as soon as any level is missing, or the hash default at that level. It raises `TypeError` only when an intermediate value has no `dig` method, for example a string. - **`review.fetch(:author).fetch(:name)`** raises `KeyError` naming exactly which key was missing. - **`review[:author][:name]`** raises `NoMethodError` on `nil` when `author` is missing, which is the least informative of the three. ## Choosing - **Required keys**, such as configuration or a payload's mandatory fields: `fetch`, so a typo or a missing setting fails at the line that needs it. - **Optional keys** with a sensible absence value: `[]`, or `fetch(key, fallback)` to make the fallback visible. - **Expensive fallbacks**: the block form of `fetch`.

  • Why prefer fetch(key) { ... } over fetch(key, default) when the fallback is expensive?
    Ruby evaluates every argument before calling the method, so `fetch(:cover, load_cover)` runs `load_cover` even when `:cover` is present. The block is only called on a miss. Use the block for anything that allocates, queries or has side effects; a literal like `0` or `""` is fine as an argument.
  • How do you read a nested review field so that absence gives nil, and how so that it gives an error?
    `review.dig(:author, :name)` returns `nil` when either level is missing, or the hash default at that level. `review.fetch(:author).fetch(:name)` raises `KeyError` naming the missing key. Choose `dig` for optional data and chained `fetch` where the field is required.
  • Does fetch use the default of a hash built with Hash.new(0)?
    No. `fetch` ignores both the default value and the default proc, so `Hash.new(0).fetch(:missing)` raises `KeyError`. Only `[]`, `dig` and `values_at` consult the hash's default.

saying these in an interview costs you the question

  • Hash#fetch returns nil for a missing key, just like []
  • fetch falls back to the default set with Hash.new(0)
  • A missing key makes Hash#[] raise KeyError
  • fetch(:k, expensive_call) only runs expensive_call when the key is missing
  • review[:body] returning nil proves the body key is absent
  • Hash#dig raises KeyError when a nested key is missing
open as a page

In Ruby, why does Hash.new([]) lose appended words while Hash.new(0) counts correctly, and what does a default proc change?

level: middleimportance: must knowfreq 70%

basics

~20 s

Hash.new(obj) returns one shared obj for any missing key and stores nothing. counts[w] += 1 works because += assigns; h[k] << x mutates the shared array and adds no key. Hash.new { |h, k| h[k] = [] } stores a fresh value per key.

open as a page

In Ruby, in what order does a Hash return its entries, and what happens to that order when you update or re-add a key?

level: juniorimportance: should knowfreq 40%

basics

~20 s

A Ruby Hash presents entries in the order their keys were first inserted. Assigning a new value to an existing key keeps its position; deleting the key and adding it again moves it to the end.

open as a page

In Ruby, what does the hash literal {title:, rating:} expand to, and where does each value come from?

level: juniorimportance: should knowfreq 38%

basics

~20 s

{title:, rating:} is shorthand for {title: title, rating: rating}: each key is a symbol and its value is what the bare name evaluates to, a local variable if one exists, otherwise a method call. It needs Ruby 3.1 or later.

open as a page

In Ruby, what do Hash#transform_keys and transform_values return, and what happens when two old keys map to the same new key?

level: middleimportance: should knowfreq 45%

basics

~20 s

transform_keys returns a new hash with each key replaced by the block's result and the values kept; transform_values keeps the keys and replaces each value. If two old keys map to the same new key, the later entry silently overwrites the earlier.

open as a page

A Ruby Hash keyed by word-pair arrays stops finding entries after the key arrays are modified; why, and what do rehash and compare_by_identity change?

level: seniorimportance: should knowfreq 28%

basics

~20 s

A key is filed under its hash value when inserted; mutating an array key changes that value, so lookups search the wrong place and miss. rehash rebuilds the index from current values. compare_by_identity matches keys by object identity instead of hash and eql?.

open as a page