skip to content

Pipes and Redirection

The plumbing layer: three standard streams, file descriptors you can duplicate and move, inline documents, and pipelines whose exit status is not what beginners assume. This is where shell earns its reputation as glue, and it is a favorite whiteboard area because the answers are exact.

part ofBashoverview, primer and where to startread it →
on this pageshow

questions

23

In a bash script, what does the redirection in `echo "usage: deploy <env>" >&2` do to the echo command's file descriptors, and why write error messages that way?

level: juniorimportance: must knowfreq 60%

answer

  1. two channels, product and commentary
  2. the number before > defaults to 1
  3. ampersand means descriptor, not filename
  4. so $( ) capture stays clean
  5. message on stderr, status non-zero

basics

~20 s

The >&2 is shorthand for 1>&2: it makes echo's stdout a duplicate of file descriptor 2, so the message is written to stderr instead of stdout. Diagnostics belong on stderr so a caller can capture, pipe or discard the script's real output without losing them.

solid answer

~40 s

`>&2` is `1>&2` with the default descriptor number omitted — it duplicates descriptor 2 onto descriptor 1 for that one command, so everything `echo` writes goes out on stderr. The reason to bother is composability. A script's stdout is its *product*: the thing a caller captures with `out=$(myscript)` or pipes into `jq`. Usage text, warnings and error messages are not product; if they ride on stdout they get swallowed by the capture, corrupt the piped data, or disappear into `> results.txt`. On stderr they stay visible to a human and out of the data path, and they still show up in a log when the caller says `2>> errors.log`. The convention pairs with exit status: print the diagnostic to stderr, then `exit 1`.

code

bash · 12 lines
bash
#!/usr/bin/env bash
log() { printf '%s %s\n' "$(date +%FT%T)" "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }

list_hosts() {
  log "warning: cache is stale"
  printf '%s\n' web-01 web-02
}

hosts=$(list_hosts)   # warning stays on the terminal, not in $hosts
printf 'captured: %s\n' "$hosts"
[[ -n $hosts ]] || die "no hosts returned"

go deeper

for a junior

Know that 1 is stdout and 2 is stderr, that >&2 sends the message to stderr, and that the ampersand is what stops 2 being read as a filename.

for a middle

Explain it as a duplication of descriptor 2 onto descriptor 1 for that single command, and show why a function that warns on stdout corrupts a caller's $( ) capture or pipeline.

for a senior

Demonstrate the convention in a real script: a log/die helper writing to stderr, a non-zero exit paired with every message, and the observation that the caller — not the script — chooses the destination file.

for a principal

Own the contract across a fleet of scripts: stdout is machine-readable product, stderr is human commentary, exit status is the signal automation reads. Decide how that maps onto structured logs and alerting in CI and cron.

## The mechanics Every process begins with three descriptors open: 0 stdin, 1 stdout, 2 stderr. The redirection operator `n>&m` tells the shell to make descriptor `n` a duplicate of descriptor `m` — after it runs, both table entries refer to the same open terminal, file or pipe. When you leave off the number before `>`, bash supplies 1. So these two are identical: ```bash echo "boom" >&2 echo "boom" 1>&2 ``` Both mean: for this `echo` only, point fd 1 at whatever fd 2 currently refers to. `echo` is unchanged — it still calls `write()` on descriptor 1, exactly as it always does. The shell rewired the destination before `echo` ever started, and the rewiring dies with the command. The next line of the script has an untouched fd 1. Note the ampersand carefully. `echo hi >2` creates a *file called 2* in the current directory; `echo hi >&2` writes to stderr. Missing that ampersand is a common way to litter a repository with files named `2`. ## Why stdout and stderr are separated at all The split exists so a program can emit two independent things down two independent channels: - **stdout is the product.** It is what the next program in the pipeline consumes, what `$( )` captures, what `> file` archives. - **stderr is the commentary.** Progress, warnings, failures — for a human or a log, never for a parser. A script that ignores the split stops being usable as a building block: ```bash # BAD: the diagnostic rides on stdout list_hosts() { echo "warning: cache is stale" echo "web-01"; echo "web-02" } hosts=$(list_hosts) # $hosts now contains the warning as a data row ``` Send the warning to stderr and the same function composes correctly: the caller's variable holds two hostnames, and the warning is still printed on the operator's terminal. ```bash list_hosts() { echo "warning: cache is stale" >&2 echo "web-01"; echo "web-02" } ``` ## The usage-and-die idiom The canonical shape in a script that takes arguments: ```bash usage() { echo "usage: deploy <env>" >&2 exit 2 } [[ $# -eq 1 ]] || usage ``` Two things travel together: the human-readable reason on stderr, and a non-zero exit status for the machine. A caller checking `if ! deploy prod; then` learns something failed from the status; a human reading the terminal learns *what* from stderr. Printing the message with no non-zero exit is the mistake to avoid — automation cannot see text, only status. A small nuance worth knowing: `--help` explicitly requested by the user is output the user asked for, so many tools print help on stdout with status 0, and print the shorter *usage* line on stderr with a non-zero status when the invocation was wrong. ## Making it reusable A one-line helper keeps the redirection in exactly one place: ```bash log() { printf '%s %s\n' "$(date +%FT%T)" "$*" >&2; } die() { log "ERROR: $*"; exit 1; } log "starting deploy" command -v kubectl >/dev/null || die "kubectl not found" ``` Use `printf` rather than `echo` for anything with backslashes or leading dashes — `echo`'s handling of `-e` and escapes varies between bash's builtin, `/bin/echo` and other shells, while `printf` is specified. ## Sending stdout to stderr's place, not vice versa Because `>&2` is a duplication of whatever fd 2 points at, it follows the caller's redirection automatically. If the caller runs `./deploy.sh 2>> /var/log/deploy.err`, the messages land in that file with no change to the script. That is the real payoff of writing to the descriptor instead of hardcoding a path or `/dev/tty`: the script states *which channel*, and the caller decides *where*. ## What an interviewer is checking That you know `>&2` is a descriptor duplication rather than magic error syntax, that you can say why the two channels exist, and that you instinctively pair a stderr message with a non-zero exit status.

  • What is the difference between `echo hi >2` and `echo hi >&2`?
    `>&2` duplicates descriptor 2 onto descriptor 1, sending the text to stderr. `>2` has no ampersand, so `2` is read as a filename: bash creates or truncates a file literally named `2` in the current directory and writes there. It is a silent typo — nothing errors, the message just vanishes from the terminal.
  • If the script writes diagnostics with `>&2`, how does a caller collect them in a file?
    The caller redirects descriptor 2 themselves, for example `./deploy.sh prod > result.txt 2>> deploy.err`, or merges both with `> all.log 2>&1`. Because the script only says *which channel*, not which file, the destination stays the caller's decision and needs no change inside the script.
  • Should a script's `--help` output also go to stderr?
    Usually not. Help the user explicitly asked for is requested output, so print it on stdout and exit 0 — that lets `myscript --help | less` work. Reserve stderr plus a non-zero status for the short usage line printed when the invocation itself was wrong.

saying these in an interview costs you the question

  • Thinking >&2 writes to a file named 2
  • Putting warnings on stdout and breaking $( ) capture
  • Believing stderr is only for crashes, not warnings
  • Printing an error message but still exiting 0
  • Assuming stderr is always the terminal

context

open as a page

In bash, the pipeline `false | true` leaves `$?` at 0. Which command's exit status does a pipeline report by default, and why does that let a failing build pass silently in a CI step?

level: juniorimportance: must knowfreq 70%

basics

~20 s

By default a bash pipeline reports only the exit status of its last command; every earlier stage's status is discarded. So false | true returns 0, and a failing build piped into tee or grep looks successful to CI.

open as a page

In bash, running `mytool > out.log` still prints error messages to the terminal instead of putting them in out.log. Which stream does a bare `>` redirect, and how do you redirect the other one?

level: juniorimportance: must knowfreq 85%

basics

~20 s

A bare > redirects only standard output, file descriptor 1. Error messages travel on standard error, descriptor 2, which stays on the terminal until you redirect it explicitly with 2> file, 2>> file, or 2>/dev/null.

open as a page

In a bash script, why does `cmd 2>&1 > out.log` behave differently from `cmd > out.log 2>&1`, and where does stderr end up in each case?

level: middleimportance: must knowfreq 72%

basics

~20 s

Bash applies redirections left to right, and each one copies where the target points at that moment. cmd > out.log 2>&1 puts stdout in the file and then makes stderr a copy of it, so both are captured. cmd 2>&1 > out.log copies stderr to the terminal first, then moves only stdout.

open as a page

In a bash script, `ssh web01 <<EOF` ... `EOF` sends a block of commands to a remote host as that command's standard input. What changes if you write the opener as `ssh web01 <<'EOF'` instead, and which form do you want when the block references variables that exist only on the remote machine?

level: middleimportance: must knowfreq 72%

basics

~20 s

Quoting the here-document delimiter (<<'EOF') sends the body through untouched; leaving it unquoted (<<EOF) makes the local shell expand $variables, $(...) and backticks in the body first. Use the quoted form for anything that must be evaluated on the remote host.

open as a page

What does `set -o pipefail` change about the exit status of a bash pipeline, and when several stages fail, which one's status do you actually get?

level: middleimportance: must knowfreq 62%

basics

~20 s

With set -o pipefail, a bash pipeline returns the status of the rightmost command that exited non-zero, or 0 if every stage succeeded. Without it, only the last stage's status counts, so earlier failures vanish.

open as a page

In bash, `while read -r line; do ((count++)); done < <(find . -name '*.log')` leaves `count` holding the file count, unlike the same loop fed by a pipe. What is `< <(...)` doing to the loop's input, and why is the space between the two `<` characters mandatory?

level: middleimportance: must knowfreq 55%

basics

~20 s

The inner <(find ...) expands to a /dev/fd path, and the separate outer < redirects the loop's standard input from it, so the loop itself runs in the current shell and its variable updates survive. Without the space, << starts a here-document.

open as a page

A colleague runs `sort data.txt > data.txt` in bash and data.txt comes back empty. Why does the file lose its contents, and what is the correct way to filter a file in place?

level: middleimportance: must knowfreq 62%

basics

~20 s

The shell performs redirections before it runs sort, so > data.txt truncates the file to zero bytes first and sort then reads an empty file. Filter into a temporary file and rename it over the original instead.

open as a page

In bash, the here-string operator appears in commands such as `grep -q error <<< "$line"`. What exactly does `<<<` feed to the command's standard input, and how does that data differ from the string held in the variable?

level: juniorimportance: should knowfreq 50%

basics

~20 s

A here-string expands the word after <<< and supplies it as the command's standard input with a single newline appended. Bash performs parameter and command substitution on the word but no word splitting or globbing, so an unquoted variable still arrives as one intact string.

open as a page

In bash, `diff <(sort a.txt) <(sort b.txt)` compares the output of two commands even though `diff` only accepts filenames. What does `<(sort a.txt)` expand to, and what is running behind that word?

level: juniorimportance: should knowfreq 42%

basics

~20 s

Process substitution runs sort a.txt as a separate process and expands to a filename, typically /dev/fd/63, that reads from that process's output. diff opens that path like an ordinary file, so the two sorts stream straight into it.

open as a page

A bash script must write progress lines to /var/log/deploy.log while its ordinary stdout still goes wherever the caller pointed it. How do you open a dedicated file descriptor once with exec, write to it, and close it when done?

level: middleimportance: should knowfreq 40%

basics

~20 s

Run exec 3>/var/log/deploy.log once near the top of the script. That opens the file on descriptor 3 for the whole shell, so later commands write to it with >&3 while stdout and stderr are untouched. Close it with exec 3>&- when finished.

open as a page

A bash script runs `build_tool | tee build.log`, then stores `rc=$?` on the next line, then checks `${PIPESTATUS[0]}` — and always sees 0 even when build_tool fails. Why, and how do you capture each stage's status correctly?

level: middleimportance: should knowfreq 48%

basics

~20 s

PIPESTATUS describes only the most recently executed pipeline, and every command overwrites it — including the plain assignment rc=$?, which resets it to a single 0. Copy the whole array on the line immediately after the pipeline: statuses=("${PIPESTATUS[@]}").

open as a page

In bash, `sudo echo hello > /etc/motd` fails with "Permission denied" even though the user has full sudo rights. Which process is doing the redirection, and how do you write that file as root?

level: middleimportance: should knowfreq 52%

basics

~10 s

Your unprivileged shell performs the redirection, opening /etc/motd as you before sudo ever runs, so the open fails. Pipe into a privileged writer instead: echo hello | sudo tee /etc/motd.

open as a page

In a bash script, how do you send every command in one section to a log file and then restore stdout to where it was before, and when is redirecting a `{ ...; }` block the better choice?

level: seniorimportance: should knowfreq 36%

basics

~20 s

Save the current stdout on a spare descriptor first: exec 3>&1, then exec 1>run.log, and restore afterwards with exec 1>&3 3>&-. Without the saved copy the original destination is unrecoverable. Redirecting a { ...; } group instead is safer because the shell restores stdout automatically when the group ends.

open as a page

A container entrypoint script generates a wrapper script with `cat > /usr/local/bin/run <<EOF` ... `EOF`. The generated wrapper must keep `"$@"` literal so it forwards its own arguments at runtime, but must bake in the value of `$IMAGE_TAG` from the entrypoint's environment. How do you get both behaviours out of one here-document, and which approach survives review?

level: seniorimportance: should knowfreq 33%

basics

~20 s

Either use an unquoted delimiter and backslash-escape every literal, writing "$@", or use a quoted delimiter for a fully literal body and emit the baked-in values as separate generated assignment lines. The second survives review, because one missed backslash in the first is a silent bug.

open as a page

After a team enables `set -o pipefail`, the CI line `zcat access.log.gz | grep ' 500 ' | wc -l` starts reporting failure on days with no HTTP 500s. Why does a successful search fail the pipeline, and how do you write the check so an empty result is not an error?

level: seniorimportance: should knowfreq 42%

basics

~20 s

grep exits 1 when it finds no matching lines, which is a result, not an error. pipefail promotes that 1 to the pipeline's status even though wc succeeded. Tolerate it explicitly with || true, or check the stage's status via PIPESTATUS and accept 1.

open as a page

A backup script runs `pg_dump mydb > >(gzip -9 > dump.sql.gz)`, reports success, and yet the .gz file is occasionally truncated while a gzip failure never fails the script. What is bash doing with `>(gzip …)`, and why is that process's exit status invisible?

level: seniorimportance: should knowfreq 30%

basics

~20 s

bash runs gzip as a separate asynchronous process and expands >(gzip …) to a /dev/fd path that feeds its stdin. $? reports pg_dump only — this is not a pipeline, so nothing propagates gzip's status — and the script can move on, or exit, while gzip is still writing.

open as a page

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%

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.

open as a page

A bash script has a here-document nested inside an indented function, opened with `<<-EOF` and closed with an indented `EOF`. Bash still warns `here-document at line N delimited by end-of-file (wanted 'EOF')`. What does the `<<-` operator actually strip, and what must the closing delimiter line contain?

level: middleimportance: nice to knowfreq 40%

basics

~20 s

The dash in <<- strips leading TAB characters only — never spaces — from both the body lines and the delimiter line. A closing line indented with spaces therefore never matches, so bash reads to end of file and swallows the rest of the script.

open as a page

Running `ssh host /opt/bin/start-agent.sh` never returns to your prompt even though the script finished and printed its last line. The script leaves a background process running. How do inherited file descriptors explain the hang, and what redirection fixes it?

level: seniorimportance: nice to knowfreq 28%

basics

~20 s

The background process inherited copies of the script's stdout and stderr, which are the pipes back to the ssh client. ssh keeps reading until every writer closes, so it waits on the still-running child. Redirect the child's descriptors away from those pipes when starting it: cmd >/dev/null 2>&1 </dev/null &.

open as a page

A bash script running with `set -o pipefail` intermittently exits with status 141 on the line `producer | head -n 20`. What does 141 mean there, why is it intermittent, and how should the script handle it?

level: seniorimportance: nice to knowfreq 34%

basics

~20 s

141 is 128 plus 13, the status bash records for a process killed by SIGPIPE. head exits after 20 lines and closes the pipe, so the producer dies on its next write, and pipefail promotes that 141 to the whole pipeline's status.

open as a page

In bash, `unzip <(curl -sL "$url")` fails to read the archive, although saving the same URL to a file and unzipping it works. Given what `<(...)` hands a command, why does unzip fail here while `diff <(...)` is perfectly happy?

level: seniorimportance: nice to knowfreq 20%

basics

~20 s

The /dev/fd path from <(...) names a pipe, which is readable once and forward only. unzip must seek to the central directory at the end of the archive before it can read entries, and a pipe cannot be seeked; diff and grep only stream forward, so the same path suits them fine.

open as a page

A bash script asks a question with `read`, but when it is run as `./setup.sh > install.log` the prompt text disappears into the log and the run looks hung. Which stream should a prompt use, and what does /dev/tty give you here?

level: seniorimportance: nice to knowfreq 30%

basics

~20 s

Prompts belong on standard error, not standard output, so they stay visible when a caller redirects the script's output to a log. Bash's read -p already prompts on stderr. /dev/tty writes straight to the controlling terminal, but fails when there is none.

open as a page