skip to content

In a bash script, `count=0; find . -name '*.log' | while read -r f; do count=$((count+1)); done; echo "$count"` prints 0 no matter how many files matched. Why is the count lost, and what are the two standard ways to restructure the loop so it survives?

level: middleimportance: must knowfreq 72%

answer

  1. the body runs, the value doesn't
  2. who executes the loop, exactly?
  3. each pipeline stage is a process
  4. children never hand variables back
  5. lose the pipe: use < or < <( )

basics

~20 s

Each stage of a bash pipeline runs in its own process, so the loop increments count inside a child shell that exits when the pipeline ends. Feed the loop from a redirection or a process substitution instead of a pipe.

solid answer

~50 s

A pipeline puts every stage in a separate process, and the whole `while ... done` compound command is one of those stages. The loop body really does run, but it runs in a forked copy of the shell: it increments that copy's `count`, and when the pipeline finishes the copy exits and takes the value with it. The parent's `count` was never touched, so `echo` prints the original 0. The fix is to stop feeding the loop through a pipe. If the input is a file, redirect it directly: `while read -r f; do ...; done < list.txt`. If the input is a command's output, use process substitution: `while read -r f; do ...; done < <(find . -name '*.log')`. Both leave the loop in the current shell, so assignments stick. `mapfile -t arr < <(cmd)` is a good alternative when you just want the lines in an array.

code

bash · 11 lines
bash
count=0
printf 'a\nb\nc\n' | while IFS= read -r line; do
  count=$((count + 1))
done
echo "piped:      $count"

count=0
while IFS= read -r line; do
  count=$((count + 1))
done < <(printf 'a\nb\nc\n')
echo "substituted: $count"

go deeper

for a junior

Recognise the symptom: a variable set inside a loop that is fed by a pipe is empty afterwards. Know the fix by shape — redirect the loop with done < file rather than piping into it.

for a middle

Explain that each pipeline stage runs as a separate process and that a child shell cannot hand variables back to its parent, then give both fixes and say which one fits file input versus command output.

for a senior

Show you spot it in review — a pipeline ending in a loop that assigns state used later — and pick a fix on merits: process substitution loses the producer's exit status, mapfile needs the data to fit in memory, lastpipe is version- and job-control-dependent.

for a principal

Frame it as a class of defect rather than a trick: silent state loss that tests with printing loops never catch. Argue for ShellCheck in CI (SC2030/SC2031) and for a house rule that loops are fed by redirection, so nobody has to rediscover this.

## The symptom ```bash count=0 printf 'a\nb\nc\n' | while read -r line; do count=$((count + 1)) done echo "$count" # prints 0 ``` Add an `echo "in loop: $count"` inside the body and you will see it climb to 3 — so the loop definitely ran, and the increment definitely worked. The value simply does not exist any more by the time the last line executes. This is the single most reported "bash is broken" bug, and interviewers use it because the code looks obviously correct. ## Why the value does not come back Every stage of a bash pipeline runs as its own process. In `printf ... | while ... done`, `printf` is one process and the *entire* `while` compound command is another: bash forks a subshell for it, wires up the pipe, and waits for both. A forked shell starts as a copy of the parent's variables, which is why `count` is visible *going in*. But the two processes have separate memory. The child raises its own `count` to 3, then exits; the parent's copy is still 0. There is no channel by which a child shell hands variables back to its parent — that is a property of processes, not of `while`. (The precise rules about what a subshell copies, and which other constructs create one, are a topic of their own; for loops the fact you need is simply that a pipe puts the loop in a child.) Nothing about this is specific to counters. Everything the body changes is lost the same way: array elements you appended, a flag you set, an `exit` status you stored, even a `cd`. The mirror-image bug is `printf 'x\n' | read var` — `var` is empty afterwards for exactly the same reason. ## Fix 1 — redirect the loop instead of piping into it When the data is already in a file, never pipe it: ```bash count=0 while IFS= read -r line; do count=$((count + 1)) done < list.txt echo "$count" # correct ``` The `< list.txt` attaches to the whole compound command, and no pipeline is created, so the loop runs in the current shell. ## Fix 2 — process substitution when the data comes from a command `<(cmd)` gives you a filename you can redirect from, which converts "command output" into the shape fix 1 wants: ```bash count=0 while IFS= read -r f; do count=$((count + 1)) done < <(find . -name '*.log') echo "$count" # correct ``` Mind the space in `< <(` — `<<(` is a syntax error. Process substitution is a bash extension, not POSIX; how it is implemented is its own topic. Note the ordering trade-off: the loop now runs in the current shell, but `find` runs in the background, so its exit status is not directly visible. ## Two more options `mapfile -t lines < <(cmd)` (bash 4.0+, also spelled `readarray`) reads the whole stream into an array in the current shell, after which you loop over the array with no subshell anywhere. It is the cleanest choice when the data fits in memory. `shopt -s lastpipe` (bash 4.2+) changes the rule so the *last* stage of a pipeline runs in the current shell — which would make the original code work. It applies only when job control is off, which is the default in scripts but not at an interactive prompt, so a snippet that works when pasted into a script may behave differently when pasted into a terminal. Because of that split behaviour most style guides prefer the redirect or process-substitution forms. ## Recognising it in review The tell is: a pipeline whose last stage is a `while`, `for`, `until` or `{ ... }` block that assigns to a variable used *after* the block. ShellCheck flags exactly this pair — `SC2030` on the assignment ("modification of count is local to the subshell") and `SC2031` on the later use. If the loop body only *prints* and never assigns, the pipe is harmless and you can leave it alone.

  • The loop only prints lines and sets nothing — is the pipe still a problem?
    No. If the body neither assigns variables used later nor changes directories, running in a subshell has no visible effect, and `cmd | while read -r x; do echo "$x"; done` is fine. The trap is specifically about state that has to outlive the loop. Reviewers should flag the assignment, not the pipe by itself.
  • Why is `shopt -s lastpipe` not the default recommendation?
    It only takes effect when job control is disabled, which is true in a non-interactive script but not at an interactive prompt, so the same code behaves differently in the two settings. It also needs bash 4.2 or newer and silently does nothing on older bash or on macOS's bash 3.2, so a redirect or `< <( )` is more predictable.
  • How would you accumulate the lines into an array instead of a counter?
    Either append inside a non-subshell loop — `while IFS= read -r l; do arr+=("$l"); done < <(cmd)` — or skip the loop entirely with `mapfile -t arr < <(cmd)`, which is bash 4.0+. Both leave `arr` populated in the current shell; the same code with `cmd | while` leaves it empty.

saying these in an interview costs you the question

  • Says the variable needs export to escape the loop
  • Claims read consumed or unset the variable
  • Thinks declare -g or a global declaration fixes it
  • Believes the loop body never executed at all
  • Suggests writing the count to a file as the primary fix

context