skip to content

A bash function `get_version` runs `echo "looking up version..."` and then `echo "1.4.2"`, and callers use `v=$(get_version)`. What does `$v` actually contain, and how should a function that both logs and produces a value be written?

level: middleimportance: should knowfreq 55%

answer

  1. the capture takes everything
  2. stdout is the return channel
  3. humans read the other stream
  4. >&2 on every log line
  5. one helper: log() … >&2

basics

~20 s

$v holds both lines — "looking up version..." and "1.4.2" — because a capture takes everything the function wrote to standard output. Keep stdout for the return value only and send every log line to standard error with >&2.

solid answer

~40 s

The capture is blind to intent: it collects all of the function's standard output, so `$v` becomes the two-line string `looking up version...` followed by `1.4.2`. Nothing errors out — the corrupted value simply flows on, into a comparison that never matches, a filename, or a config file. The fix is a channel discipline: standard output is the function's return value, standard error is for humans. Write a one-line helper such as `log() { printf '%s\n' "$*" >&2; }` and use it for every progress, warning and debug line, so the messages still reach the terminal or the job log while `v=$(get_version)` captures only `1.4.2`. The same rule applies to anything the function calls: a helper that chats on stdout pollutes the caller's capture just as effectively.

code

bash · 11 lines
bash
#!/usr/bin/env bash

log() { printf '%s\n' "$*" >&2; }   # diagnostics never touch stdout

get_version() {
  log "looking up version..."
  printf '%s\n' "1.4.2"             # the payload
}

v=$(get_version)                     # the log went to the terminal
printf 'version=[%s]\n' "$v"         # version=[1.4.2]

go deeper

for a junior

Remember that $(...) captures every line a function prints, not just the last one, and that log messages therefore belong on standard error via >&2.

for a middle

Explain the channel contract and write the fix: a log helper that does printf ... >&2, leaving standard output for the value alone. Know that >&2 and 2>&1 point in opposite directions.

for a senior

Talk about how you find this in a real codebase — a chatty helper deep in a call chain, a caller that added 2>&1, a tool that prints a banner — and how a corrupted value surfaces hours later in a generated config rather than at the call site.

for a principal

Own it as a library rule the whole team can apply without thinking: functions return on stdout, speak to humans on stderr, and structured output uses one agreed encoding. Be ready to say when text on stdout has stopped being a sane interface at all.

## The capture is literal Command substitution has no idea which of a function's output lines were meant as the answer. It takes the function's entire standard output. So with ```bash get_version() { echo "looking up version..." echo "1.4.2" } v=$(get_version) ``` `$v` is the two-line string `looking up version...` and `1.4.2`. The assignment succeeds, the exit status is 0, and nothing warns you. ## Why this bug is expensive The damage shows up far from the cause, which is what makes it an interview favourite: - `[[ "$v" == "1.4.2" ]]` fails, and the script takes the wrong branch; - `mkdir "/opt/app/$v"` creates something unusable, or fails on the embedded newline; - `curl ".../release/$v"` requests a nonsense path; - the value is written into a generated config file and breaks a service at startup, hours later. And the trigger is often innocent: someone adds one `echo "starting…"` to a function that has worked for a year. ## The convention: stdout is the value, stderr is for humans Every well-behaved Unix command already works this way — `ls` prints names on stdout and "No such file" on stderr, precisely so that `files=$(ls)` is not poisoned by the error text. Shell functions adopt the same contract: ```bash log() { printf '%s\n' "$*" >&2; } warn() { printf 'WARN: %s\n' "$*" >&2; } get_version() { log "looking up version..." printf '%s\n' "1.4.2" } ``` Now `v=$(get_version)` is exactly `1.4.2`, and the log line still appears on the terminal, in the CI job output, or wherever the caller's standard error is pointed. Nothing is lost; it just travels on the other channel. Note `>&2` and not `2>&1`: `>&2` makes *this command's* standard output go where standard error currently points, which is what a log line needs. `2>&1` is the opposite direction and, applied to a function call, would merge the diagnostics back into the capture. ## What still leaks A few things reintroduce the problem even after you adopt the convention: - **The caller merges the channels.** `v=$(get_version 2>&1)` deliberately folds stderr back into the capture. It is sometimes what you want when you are storing an error message; it is never what you want when you are storing a value. - **A helper that prints.** If `get_version` calls `resolve_repo`, and *that* function echoes a progress note, the note lands in the same capture. The discipline has to hold all the way down the call chain. - **A prompt.** Prompting the user from inside a value-producing function is a trap; `read -p` writes its prompt to standard error precisely because of this hazard, so hand-rolled prompts must do the same. - **Chatty external commands.** Some tools print banners or progress to stdout. Redirect them per call: `out=$(sometool --quiet 2>/dev/null)` or send the noise to `/dev/null` explicitly. ## Returning more than one value Once stdout is a data channel it can carry structure — as long as the caller agrees on the encoding. Common choices are one record per line for `while read` loops, tab-separated fields with `IFS=$'\t' read -r a b c`, or NUL separators when the data may contain newlines. Anything richer than that is usually a sign the function should be filling in caller-supplied variables instead of printing. ## How to verify it The cheap test is to run the capture and look at the value with delimiters: `v=$(get_version); printf 'value=[%s]\n' "$v"`. If a log line is inside the brackets, the function is broken. In a script under review, the smell is any `echo` in a function whose callers use `$( )`, and any function that both logs and returns without a `log` helper in sight.

  • Why `>&2` on the log line rather than `2>&1` on the call?
    `>&2` sends that one command's standard output to wherever standard error points, keeping the message off the data channel at the source. `2>&1` does the reverse — it folds standard error into standard output — so applying it to the function call merges the diagnostics straight back into the captured value. Direction matters.
  • Is there ever a reason to capture a function's stderr on purpose?
    Yes: when the message *is* the data you want. `err=$(risky_op 2>&1 >/dev/null)` keeps only the diagnostics, which is useful for including a tool's complaint in your own error report or in a test assertion. The point is to choose that explicitly, not to inherit it by accident when you wanted a value.
  • What if a value-producing function also needs to ask the user something?
    Send the prompt to standard error and read from the terminal, or better, do not prompt inside a value-producing function at all — take the answer as an argument and let the top-level script own all interaction. Bash's own `read -p` writes its prompt to standard error for exactly this reason.

saying these in an interview costs you the question

  • Assumes echo inside a function always reaches the terminal
  • Thinks $( ) captures only the last line printed
  • Uses 2>&1 on the call to "fix" a polluted capture
  • Adds a debug echo to a function other code captures
  • Believes stderr output would break the script, so logs on stdout

context