skip to content

In a bash script whose output other programs consume, why should progress and warning messages go to standard error while only results go to standard output?

level: seniorimportance: should knowfreq 45%

answer

  1. two streams, two audiences
  2. which one does a pipeline capture
  3. noise inside the captured value
  4. the caller wants an independent mute
  5. block-buffered once it is not a terminal

basics

~20 s

Standard output is the script's data channel: whatever lands there is what a pipeline, a redirect or a command substitution captures. Putting progress messages there corrupts the caller's data, while standard error keeps them visible and independently silenceable.

solid answer

~50 s

Think of the two streams as two interfaces. Descriptor 1 is the machine-readable product — it is what `script.sh | jq`, `script.sh > data.csv` and `value=$(script.sh)` all capture. Descriptor 2 is the channel to the human, and it survives every one of those redirections. If a script prints `Fetching page 3...` with plain `echo`, that line becomes part of the data: a caller counting lines gets an extra one, a caller parsing JSON gets a syntax error, and a caller assigning the output gets the noise inside the variable. Routing diagnostics with `printf '...' >&2` fixes all three at once and gives the caller a clean control: `2>/dev/null` silences the chatter without touching the data, and `>/dev/null` keeps the chatter while dropping the data. A small `log()` helper that writes to stderr with a timestamp is the usual way to make this the path of least resistance in a script.

go deeper

for a junior

Remember the split: results on standard output, messages for the human on standard error via >&2. Know that $(...) and pipes capture only stdout, so anything you echo there becomes part of the data.

for a middle

Explain concretely what breaks — a polluted command-substitution value, a broken | jq, an extra line in a CSV — and show the log() { ...; } >&2 helper. Describe the 2>/dev/null versus >/dev/null controls it gives the caller.

for a senior

Bring the operational depth: buffering differences that scramble a merged log, stdbuf -oL and per-tool flags, and the insistence that exit status, not printed text, is how failure is reported to a caller.

for a principal

Treat stdout as a published interface for internal tooling: its shape is a contract, changes to it break callers, and diagnostics must never leak into it. That is what allows scripts to be composed, wrapped and later replaced by real programs without touching every call site.

## Two streams, two audiences Every process starts with descriptor 1 (standard output) and descriptor 2 (standard error) open, and by universal convention they carry different things: - **stdout — the product.** What the command was asked to produce, in a form another program can consume. - **stderr — the commentary.** Progress, warnings, errors, anything a human reads to understand what happened. The convention only pays off if you honour it, because callers make plans based on it. `ls`, `grep`, `curl` and `jq` all do; a script that does not is a bad citizen in every pipeline it appears in. ## What breaks when diagnostics land on stdout Every way a caller consumes a script picks up the pollution. **Command substitution.** `$(...)` captures stdout only: ```bash # script.sh prints "Fetching..." and then the value, both to stdout value=$(./script.sh) # value is "Fetching...\n42", not "42" ``` The assignment succeeds and looks fine; the bug shows up later as an arithmetic error or a nonsense comparison. **Pipelines.** `./script.sh | jq '.id'` dies on the first non-JSON word. `./script.sh | wc -l` reports a count that silently includes the progress lines. **File output.** `./script.sh > report.csv` produces a CSV with a banner line in it, which the next tool either rejects or, worse, parses. Move the same messages to descriptor 2 and all of those callers work unchanged, while the messages remain on the terminal where a human can see them. ## The mechanics in a script The idiomatic form is a one-line helper, so that being correct is easier than being wrong: ```bash log() { printf '%s %s\n' "$(date +%FT%T)" "$*" >&2; } die() { log "FATAL: $*"; exit 1; } log "starting run" printf '%s\n' "$result" # the product, on stdout ``` Prefer `printf` over `echo` for anything with backslashes or a leading `-`, since `echo`'s handling of those varies between shells and builds. ## The controls this hands the caller Once the split is honest, the caller gets orthogonal switches: ```bash ./script.sh 2>/dev/null # keep the data, silence the chatter ./script.sh >/dev/null # keep the chatter, drop the data ./script.sh >data 2>run.log # separate both for later inspection ./script.sh >run.log 2>&1 # merge both for a human-read log ``` None of that is available if everything is on one stream; the caller is reduced to `grep -v` on the messages they hope to remove, which breaks the moment a message changes. ## Buffering, and why a merged log can look out of order When a script's two streams are merged into one file, entries sometimes appear in an order that contradicts the code. The reason is buffering in the C runtime that most external tools use: stdio makes stdout **line-buffered when it is a terminal but block-buffered when it is a file or a pipe**, while stderr is unbuffered. A tool's stdout can therefore sit in a 4 KB buffer, flushed only when full or at exit, while its stderr lines hit the file immediately. This catches people diagnosing a hung job: the log shows errors but none of the normal progress, so the job looks stuck at a much earlier point than it is. It is a buffering artefact, not a stall. GNU coreutils ships `stdbuf` to change a child's buffering (`stdbuf -oL mytool`), and many tools have their own flag — `grep --line-buffered`, `python -u`. Bash's own builtins are not affected; the issue belongs to the external programs a script calls. ## When the data *is* log-like The convention still holds for a script whose product is human-oriented text — a report, a rendered table. That report is the product, so it goes to stdout, and the script's own commentary about producing it goes to stderr. Ask "would a caller want to capture this?", not "is this prose?". Also worth saying: neither stream is how a script reports success. That is the exit status, and a script that prints `ERROR:` to stderr while exiting 0 has broken a different contract that every caller relies on just as heavily.

  • Why is `2>/dev/null` a better silencing tool for a caller than piping through `grep -v`?
    It is structural rather than textual. `2>/dev/null` drops the whole diagnostic channel regardless of wording, and cannot accidentally remove a matching line of real data. A `grep -v` filter runs on the data stream, breaks the moment a message is reworded, and risks deleting legitimate output that happens to match the pattern.
  • Your script writes an error to stderr but still exits 0. Why is that a problem?
    Callers branch on the exit status, not on text. `if ./script.sh; then` and `set -e` both see success, so a failed step is treated as complete and the pipeline carries on with missing or partial data. Diagnostics explain a failure to a human; the status is what reports it to a program, and the two must agree.
  • How do you keep a merged log from appearing out of order?
    The reordering comes from stdio buffering in the external tools: their stdout is block-buffered once it is not a terminal, while stderr is unbuffered. Force line buffering per tool — `stdbuf -oL cmd`, `grep --line-buffered`, `python -u` — or keep the two streams in separate files and correlate them by timestamp instead of merging.

saying these in an interview costs you the question

  • Treating stderr as only for fatal errors
  • Echoing progress into a script's data output
  • Silencing everything with one blanket redirect
  • Assuming merged log order reflects execution order
  • Reporting failure in text while exiting zero

context