skip to content

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%

answer

  1. stdout was redirected, stderr was not
  2. which channel a human reads
  3. read -p already picked a stream
  4. the terminal behind the descriptors
  5. no controlling terminal, no prompt

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.

solid answer

~50 s

Standard output is the script's data channel and a caller is entitled to redirect it; anything the human must see — prompts, progress, warnings — belongs on standard error. That is why bash's own `read -p 'Continue? '` writes its prompt to descriptor 2, and only when input is coming from a terminal. If you built the prompt with `echo` you put it on stdout and it went into `install.log`. A stronger option when both streams may be redirected is `/dev/tty`, which is the process's controlling terminal regardless of any redirection: `read -r -p 'Continue? ' answer < /dev/tty`. It has a hard failure mode — a cron job, a systemd unit or a CI step has no controlling terminal, so opening `/dev/tty` errors. The production answer is not to prompt at all when nobody is there: gate it on `[ -t 0 ]` and take a `--yes` flag or an environment variable otherwise.

go deeper

for a junior

Use read -r -p rather than an echo prompt, and know that its prompt goes to standard error so a redirected log does not swallow it. Always pass -r.

for a middle

Explain that stdout belongs to the caller and diagnostics belong on stderr, and describe what /dev/tty refers to. Be able to say why a redirected script appears to hang rather than error.

for a senior

Demonstrate the detection habit: [ -t 0 ] to decide whether to prompt, a documented flag or environment variable for the non-interactive path, read -t to bound the wait, and a safe default for destructive actions.

for a principal

Set the contract for the fleet: scripts must run unattended by default, with interactivity as an optional layer, so the same artifact works from a terminal, a pipeline and a scheduler. That decision is what makes automation of existing tooling possible later.

## Why the prompt vanished A script inherits three descriptors and the caller controls all three. `./setup.sh > install.log` rewires descriptor 1 only. Anything the script wrote with `echo` or `printf` — including the question it wanted a human to answer — followed descriptor 1 into the file. Descriptor 0 is still the terminal, so `read` is genuinely waiting for input; the user just has no idea, and the run looks frozen. This is one of the most common "my CI job hangs forever" root causes. The rule that avoids it: **stdout is for the product, stderr is for the conversation with the human.** A prompt is conversation. ## What `read -p` already does Bash's `read` builtin has the right behaviour baked in. Its `-p` option displays the prompt **on standard error**, without a trailing newline, and only if input is coming from a terminal. Both halves matter: ```bash read -r -p "Delete everything? [y/N] " answer ``` Redirect stdout to a log and the prompt is still on screen. Feed the script from a pipe or a file and the prompt is suppressed entirely rather than polluting a non-interactive run. Use `-r` as a matter of habit so backslashes in the answer are taken literally. If you write your own prompt, send it to descriptor 2 explicitly and use `printf` rather than `echo` so you control the trailing newline: ```bash printf 'Continue? [y/N] ' >&2 read -r answer ``` ## `/dev/tty`: the terminal behind the redirections `/dev/tty` is a special file that always refers to the *controlling terminal* of the current process — the terminal the session is attached to, whatever descriptors 0, 1 and 2 currently point at. That makes it the last resort when even stderr has been redirected: ```bash printf 'Passphrase: ' > /dev/tty read -r -s answer < /dev/tty printf '\n' > /dev/tty ``` This is how tools like `ssh` and `sudo` manage to prompt for a password inside a pipeline. It also lets a script read a human's answer while its stdin is busy carrying data. The cost is that `/dev/tty` is not a fallback, it is a stronger requirement. A process with no controlling terminal — anything started by cron, by a systemd unit, by a container entrypoint, by a CI runner — cannot open it, and the redirection fails. Hard-coding `< /dev/tty` turns "my script asked a question nobody answered" into "my script crashed on line 12", which is arguably better, but it is still a broken automated run. ## Deciding whether to prompt at all The senior answer is to make interactivity a detected condition, not an assumption. The `test`/`[` operator `-t` reports whether a descriptor is attached to a terminal: ```bash if [ -t 0 ] && [ -t 2 ]; then read -r -p "Delete everything? [y/N] " answer < /dev/tty else answer="${FORCE_YES:-n}" fi ``` That gives one script two coherent personalities: it converses with a human at a terminal, and it takes a documented default (or a `--yes` flag, or `FORCE_YES=1`) when nobody is there. The non-interactive branch should default to the *safe* choice — declining a destructive action — so an unattended run fails closed. Two more habits round it out. `read -t 30` bounds the wait so a half-interactive environment cannot hang a pipeline forever; `read` returns a non-zero status on timeout or on end-of-file, so always branch on it rather than assuming the variable was set. And running unattended jobs with `< /dev/null` guarantees that any tool which decides to read stdin gets an immediate EOF instead of blocking — a belt-and-braces measure for third-party commands you do not control. ## Where the boundary sits The general contract is worth stating to an interviewer explicitly: a script may not assume it owns the terminal. Someone will run it from cron, pipe it into `jq`, or wrap it in a CI step, and every one of those callers is exercising a legitimate right over descriptors 0, 1 and 2. Design the interactive path as an enhancement over a fully non-interactive one, not the other way round.

  • Why does hard-coding `< /dev/tty` break a script that runs under cron?
    A cron job has no controlling terminal, so opening /dev/tty fails and the redirection aborts that command. It converts a silent hang into a loud error, which is an improvement, but the run still fails. Gate the prompt on `[ -t 0 ]` and take a default, a flag or an environment variable when no terminal is attached.
  • What does `read` return when its input reaches end-of-file, and why does that matter for unattended runs?
    It returns a non-zero status and leaves the variable empty or partially set. A script fed from `/dev/null` therefore falls straight through the prompt, so any code that assumes the variable holds `y` or `n` acts on an empty string. Always branch on read's status and supply an explicit safe default.
  • When should a script prompt at all, versus requiring a flag?
    Prompt only as an enhancement for a human at a terminal, and make the non-interactive path complete on its own. Destructive actions should require an explicit `--yes` or equivalent, defaulting to refusal when nobody is there, so an unattended run fails closed rather than proceeding on a guessed answer.

saying these in an interview costs you the question

  • Printing prompts with echo to standard output
  • Assuming a script always has a terminal attached
  • Treating /dev/tty as a universal fallback
  • Ignoring read's exit status on EOF
  • Defaulting to yes when input is unavailable

context