In a bash script, `count=0; cat ids.txt | while read -r id; do count=$((count+1)); done; echo "$count"` prints 0 however many lines the file has. Why does the count vanish, and how do you make it survive the loop?
answer
- the pipe, not the loop
- each stage is its own process
- the child got a copy
- copies never flow back upward
- replace the pipe with done < file
basics
~20 sEvery stage of a bash pipeline runs in its own forked subshell, so the while loop increments a copy of count that dies when the pipeline finishes. Feed the loop by redirection instead of through a pipe.
solid answer
~50 sBash implements a pipeline by forking a child process for each stage, so `while read -r id; do ...; done` on the right of the pipe runs in a subshell. That child inherits a *copy* of `count`, increments the copy, and exits; there is no mechanism for a child process to write a variable back into its parent, so the parent still sees 0. The loop body really did run — only its state was discarded. The standard fix is to stop using a pipe: `while read -r id; do count=$((count+1)); done < ids.txt` keeps the loop in the current shell, because a redirection does not fork. If the input comes from a command rather than a file, use process substitution — `done < <(some-cmd)` — so the command is the child and the loop is not. As a bash-only alternative, `shopt -s lastpipe` runs the final pipeline stage in the current shell.
code
bash · 10 lines#!/usr/bin/env bash
printf 'a\nb\nc\n' > /tmp/ids.txt
n=0
cat /tmp/ids.txt | while read -r _; do n=$((n+1)); done
echo "piped: $n" # 0
n=0
while read -r _; do n=$((n+1)); done < /tmp/ids.txt
echo "redirected: $n" # 3go deeper
Recall the rule: a pipe puts the loop in a separate process, so counters and flags set inside it are gone afterwards. Be ready to say plainly that you would feed the loop with a redirect instead of a pipe.
Explain the mechanics — bash forks a child per pipeline stage, the child gets a copy of the variables, and nothing copies them back — and then write both the redirect and the process-substitution rewrite from memory.
Show how you catch this in code review: any pipe whose right-hand side assigns state the script later reads. Describe the patterns you standardise on, and note that side-effect-only loops are unaffected so you are not chasing every pipe.
Frame it as a language-level constraint: bash offers child processes no way to return structured state, only exit codes and bytes. A script that must accumulate significant state across a data stream is usually signalling that it has outgrown the shell.
## What the script looks like it does Read visually, the snippet says: set `count` to zero, walk the lines, add one per line, print the total. Nothing about the syntax hints that the loop is executing somewhere else. That is exactly why this is one of the most-asked bash questions — the code is a lie about its own process structure. ## Bash forks every stage of a pipeline A pipeline `a | b | c` is not three commands run by one shell. Bash creates a pipe for each `|`, then forks a child process per stage and wires their standard input and output to the pipe ends. The stages run *concurrently*, each in its own process. This is true even when a stage is a shell construct rather than an external program: a `while` loop, an `if`, a function call, a brace group — put any of them on either side of a `|` and bash must fork, because there is no other way to give that code its own end of the pipe while the rest of the pipeline runs. So the real structure of the snippet is: ```bash count=0 # in the script's own shell process cat ids.txt # child 1 while read -r id; do ... done # child 2 <- count is incremented here echo "$count" # back in the script's own shell process ``` ## Copies, not shared memory A forked child starts as a duplicate of the parent: same variables, same working directory, same open file descriptors. From the moment of the fork, the two processes are independent. The child's `count=$((count+1))` writes to the child's own memory. When the pipeline ends, the child exits and its entire variable space is destroyed. There is no "merge back" step, and no option to add one. Environment inheritance in a shell flows in one direction only — parent to child, at fork time. This is why `export count` before the pipeline changes nothing: exporting controls whether a variable is placed in the child's environment, not whether the child can reach into the parent's. The same rule explains three sibling surprises: a `cd` inside a piped loop does not move the script, an `exit` inside a piped loop only ends that stage, and an array built inside a piped loop is empty afterwards. ## Fix 1 — redirect instead of piping ```bash count=0 while read -r id; do count=$((count+1)); done < ids.txt echo "$count" # correct ``` A redirection is not a fork. Bash simply opens the file and points the loop's standard input at it, all inside the current shell, so `count` is the real variable. This is also strictly cheaper — it removes both the `cat` process and the loop's subshell. Any `cat file | while ...` should be rewritten this way on sight. ## Fix 2 — process substitution when the input is a command When the data comes from a program you cannot replace with a file, invert which side forks: ```bash count=0 while read -r line; do count=$((count+1)); done < <(git ls-files) echo "$count" ``` `< <(cmd)` runs `cmd` as a separate process (which is fine — you want nothing back from it) and hands the loop a file descriptor to read. The loop itself stays in the current shell. Mind the space between `<` and `<(`. This is a bashism, not POSIX. ## Fix 3 — bring the data in first If the data set is small, read it into an array and loop over that; `mapfile -t lines < <(cmd)` (bash 4+) does it in one step. The loop then iterates in-process with no pipe at all. ## Fix 4 — lastpipe Bash 4.2 added `shopt -s lastpipe`, which runs the *last* stage of a pipeline in the current shell when job control is inactive — the default in scripts. With it set, the original snippet works unchanged. It is a global change to pipeline semantics for the whole file, and it does not exist in bash 3.2 or in POSIX shells, so most teams prefer the redirect. ## Spotting it in review The smell is a pipe whose right-hand side assigns state that is read afterwards: a counter, a flag such as `found=1`, an accumulating string, an array append. If the loop only *prints* or *writes to a file*, the subshell is harmless. Once it assigns something the script later needs, the pipe is a bug.
- Is `done < ids.txt` really running in the current shell, or is redirection just another way of forking?It genuinely runs in the current shell. A redirection only rearranges the shell's own file descriptors before executing the command — no new process is created. You can prove it by assigning a variable in the loop and reading it after `done`, or by comparing `$BASHPID` inside and outside the loop; both show the same process.
- Why doesn't `export count` before the pipeline make the update visible afterwards?Export controls what goes *into* a child's environment when it is created; it creates no channel back. The subshell still gets a copy, still increments the copy, and still exits without touching the parent. No shell feature propagates a variable from child to parent — that is a property of the process model, not a missing bash option.
- The loop only writes each line to a log file rather than assigning anything. Is the subshell still a problem?No. The fork discards variable state, not side effects on the outside world: writes to files, network calls, and process exits all happen normally. The subshell matters exactly when the parent needs something back. Watch two things anyway — an `exit` inside the loop ends only that stage, and the extra fork costs a little per invocation.
Handing a colleague a photocopy of your ledger: they can write on it all day, but your original never changes, and when they leave they take their copy with them.
saying these in an interview costs you the question
- Says the pipe copies the variable back when the loop ends
- Claims read needs a subshell in order to work at all
- Blames cat's output buffering rather than the fork
- Thinks export before the pipeline makes the update propagate back
- Concludes the loop body never executed at all