skip to content

In Ruby, how do ENV[] and ENV.fetch differ for reading configuration, and what type are ENV values?

level: middleimportance: should knowfreq 48%

answer

  1. nil versus KeyError
  2. default argument or block
  3. values are frozen Strings
  4. assigning nil deletes
  5. children inherit it

basics

~10 s

ENV["NAME"] returns the value or nil; ENV.fetch("NAME") raises KeyError when it is missing, or returns a default or block result. Values are always frozen Strings, so convert numbers and booleans explicitly.

solid answer

~40 s

`ENV` is a hash-like object, not a `Hash`, over the process environment. `ENV["PORT"]` returns the value or `nil` when unset, so a typo or a missing deploy setting silently becomes `nil`. `ENV.fetch("PORT")` raises `KeyError` (`key not found: "PORT"`) instead; `ENV.fetch("PORT", "3000")` and `ENV.fetch("PORT") { |k| ... }` supply a fallback, and giving both warns that the block wins. Values are always **frozen Strings**: `"3000"`, not `3000`, and `"false"` is truthy, so convert with `Integer(...)` or an explicit comparison. Writes accept only strings: `ENV["PORT"] = 3000` raises `TypeError`, and assigning `nil` deletes the variable. Changes are process-wide and inherited by child processes; to change one child's environment, pass a hash to `system` or `spawn` instead.

code

ruby · 10 lines
ruby
ENV["FFMPEG_THREADS"]                 # => nil when unset
ENV.fetch("FFMPEG_THREADS")           # KeyError: key not found: "FFMPEG_THREADS"

threads = Integer(ENV.fetch("FFMPEG_THREADS", "2"))   # => 2
verbose = ENV.fetch("VERBOSE", "false") == "true"      # not just ENV["VERBOSE"]

ENV.fetch("UPLOAD_DIR").frozen?        # => true
ENV["FFMPEG_THREADS"] = 4              # TypeError: no implicit conversion
ENV["FFMPEG_THREADS"] = "4"            # ok, a String
ENV["FFMPEG_THREADS"] = nil            # deletes it

go deeper

for a junior

Recall that ENV[] returns nil for a missing name while ENV.fetch raises KeyError, and that values are Strings.

for a middle

Explain fetch's default and block forms, frozen String values, string-only writes, deletion with nil, and passing an env hash to a child.

for a senior

Show configuration discipline: fail at boot with fetch, convert types strictly, never mutate ENV per request, and keep secrets out of child environments.

for a principal

Decide where configuration is read and validated, one boot-time object with typed settings, so ENV access does not spread through the codebase.

## What ENV is Every process carries a set of **environment variables**, name-value pairs inherited from its parent. Ruby exposes them through **`ENV`**, a special object that behaves like a hash but **is not a `Hash`**: `ENV.class` is `Object`. It supports most hash-style methods (`[]`, `fetch`, `key?`, `each`, `slice`, `to_h`), and reads and writes go straight to the real process environment. ## Reading: [] versus fetch | Call | Variable present | Variable missing | |---|---|---| | `ENV["PORT"]` | `"3000"` | `nil` | | `ENV.fetch("PORT")` | `"3000"` | raises `KeyError` | | `ENV.fetch("PORT", "3000")` | the value | `"3000"` | | `ENV.fetch("PORT") { \|name\| ... }` | the value | the block's result | `ENV[]` makes missing configuration look like an intentional `nil`, which then fails far from the cause, for example as `NoMethodError` inside a connection library. `ENV.fetch` fails **at boot**, with the variable's name in the message. The usual convention is: 1. `ENV.fetch("DATABASE_URL")` for anything required, so a bad deploy stops immediately. 2. `ENV.fetch("WORKERS", "2")` for settings with a sensible default. 3. `ENV.fetch("TOKEN") { raise ConfigError, "TOKEN missing" }` when you want your own error. If you pass both a default and a block, Ruby warns `block supersedes default value argument` and uses the block. ## Values are frozen Strings The environment stores text, so every value Ruby returns is a **`String`**, and a **frozen** one: - `ENV["PORT"]` is `"3000"`; arithmetic needs `Integer(ENV.fetch("PORT"))`, which also raises on garbage like `"30o0"`, whereas `to_i` would quietly return `30`. - `ENV["DEBUG"] = "false"` is **truthy** in Ruby, since only `nil` and `false` are falsy. Compare explicitly: `ENV.fetch("DEBUG", "false") == "true"`. - `ENV["NAME"] << "x"` raises `FrozenError`; build a new string instead. ## Writing - `ENV["MODE"] = "batch"` sets a variable for this process and for children started afterwards. - The value must be a `String` or respond to `to_str`: `ENV["PORT"] = 3000` raises `TypeError`. - `ENV["MODE"] = nil` or `ENV.delete("MODE")` removes it. - Names cannot be empty or contain `=`, and neither names nor values may contain a NUL byte. ## Scope: the whole process `ENV` is **process-global**. A change made in one thread is visible to every thread and to every child started later. That is why per-request or per-job settings should never be passed by mutating `ENV`. For one child only, pass an environment hash as the first argument: ```ruby system({"TMPDIR" => "/srv/work/tmp"}, "ffmpeg", "-i", src, dst) ``` A `nil` value in that hash removes the variable for the child, and `unsetenv_others: true` starts the child with only the variables you list. ## Traps interviewers like 1. `ENV.fetch("PORT", 3000)` returns the Integer `3000` when the variable is unset but the String `"3000"` when it is set, because the default is returned exactly as given. Pass a String default and convert once. 2. A variable set to the empty string exists: `ENV["TOKEN"]` is `""`, which is truthy, and `ENV.fetch` does not raise. Check for emptiness when that matters. 3. `ENV.to_h` is a snapshot `Hash`; later changes to `ENV` do not appear in it. ## Testing code that reads ENV - Read configuration once at boot into a plain object, then inject that object; tests then never touch `ENV`. - If a test must change `ENV`, save and restore the original value in an `ensure`, because leftover values leak into later tests. ## Summary - `fetch` for required settings, `fetch` with a default for optional ones, `[]` only when `nil` is a meaningful answer. - Convert strings explicitly; `"false"` is truthy. - Keep `ENV` read-only after boot, and pass per-child environments as a hash.

  • In Ruby, why is ENV["FEATURE_ON"] a poor boolean check?
    Every ENV value is a String, and any String, including `"false"` and `"0"`, is truthy in Ruby. `if ENV["FEATURE_ON"]` is therefore true whenever the variable is set at all. Compare against the expected text, for example `ENV.fetch("FEATURE_ON", "false") == "true"`.
  • In Ruby, how do you give one child process an extra environment variable without changing ENV?
    Pass a hash as the first argument: `system({"LC_ALL" => "C"}, "sort", "list.txt")`. The same works for `spawn`, `exec` and the Open3 methods. The hash applies only to that child, a `nil` value removes a variable for it, and your process's `ENV` is untouched.

saying these in an interview costs you the question

  • ENV["PORT"] returns an Integer when the value looks numeric.
  • ENV.fetch returns nil for a missing variable, just like ENV[].
  • ENV is an ordinary Hash, so ENV.class is Hash.
  • Setting ENV["PORT"] = 3000 stores the number for child processes.
  • Changing ENV inside one thread affects only that thread.