skip to content

Loops and Iteration

for over a list, C-style for over numbers, and while/until over a condition — plus the single most-asked shell loop question: why variables set inside `cmd | while read` are gone once the loop ends. Reading a file line by line safely has one canonical spelling and interviewers expect it.

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

questions

6

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

open as a page

The canonical bash idiom for reading a file line by line is `while IFS= read -r line; do ...; done < file.txt`. What does each of `IFS=`, `-r` and the redirection after `done` contribute, and what breaks if you leave them out?

level: middleimportance: must knowfreq 66%

basics

~20 s

IFS= stops read stripping leading and trailing whitespace from the line, -r stops it treating backslashes as escapes, and the redirection after done feeds the whole loop from the file so it stays in the current shell.

open as a page

A bash cleanup script uses `for f in $(ls *.log); do rm -- "$f"; done` and fails with "No such file or directory" on a file named `app server.log`. Why does the loop break, and what is the correct way to iterate over those files?

level: juniorimportance: should knowfreq 60%

basics

~20 s

Command substitution output is split on whitespace, so a filename containing a space becomes two loop items. Drop ls entirely and let the glob supply the list: for f in *.log, which yields one item per file no matter what the names contain.

open as a page

In a bash script, `n=5; for i in {1..$n}; do echo "$i"; done` prints `{1..5}` instead of the numbers 1 through 5. Why does that happen, and which loop forms do iterate a variable number of times?

level: middleimportance: should knowfreq 52%

basics

~20 s

Brace expansion runs before parameter expansion, so bash sees {1..$n}, does not recognise it as a numeric range, and leaves it as text that later becomes {1..5}. Use a C-style loop, for ((i=1; i<=n; i++)), or iterate over seq output.

open as a page

A bash script runs `while read -r host; do ssh "$host" uptime; done < hosts.txt` but only ever connects to the first host in the file. What is consuming the rest of the input, and how do you fix the loop?

level: seniorimportance: should knowfreq 38%

basics

~20 s

The loop body inherits the loop's standard input, and ssh reads standard input to forward it to the remote command — so the first ssh drains hosts.txt. Run ssh with -n, or redirect its input from /dev/null.

open as a page

In a bash script, a `for` loop is nested inside a `while` loop. What do `break 2` and `continue 2` do in the inner body, and what does a plain `break` inside a `case` block within a loop affect?

level: juniorimportance: nice to knowfreq 30%

basics

~20 s

break 2 exits two levels of enclosing loop, so both the for and the while; continue 2 abandons the current inner iteration and starts the next iteration of the outer loop. A break inside case affects the enclosing loop, because case is not a loop.

open as a page