Linux & Bash · Part I · Module 17 · Loops and Iteration
break, continue and Leaving a Loop Early
Two keywords, almost no syntax, and one consequence that catches careful people: a loop that broke out early reports success, exactly like a loop that ran to the end. So the statement that stops your search cannot also tell the caller whether the search succeeded.
Intermediate17 min readLinuxPart IBashLoopsExit StatusScripting
Part I · Module 17 · Chapter 17.6 · break, continue and Leaving a Loop Early
Every loop so far ran to the end of its list or until its condition flipped. Often you want out sooner — you found what you were looking for, or this item is not worth processing.
1. The Two Keywords
$ for i in 1 2 3 4 5; do [ "$i" -eq 3 ] && break; printf '%s ' "$i"; done; echo
1 2break leaves the loop immediately. Nothing after it in the body runs, and no further iterations happen.
$ for i in 1 2 3 4 5; do [ "$i" -eq 3 ] && continue; printf '%s ' "$i"; done; echo
1 2 4 5continue abandons this iteration and starts the next one. Nothing after it in the body runs for this pass; the loop itself carries on.
Both work identically in for, for (( )), while and until. And both are only meaningful inside a loop:
$ bash -c 'break'
bash: line 0: break: only meaningful in a `for', `while', or `until' loop2. continue Is the Filter Idiom
By far the most common use. Rather than wrapping the whole body in an if, reject the items you do not want at the top and let the rest fall through:
$ printf '# comment\n\nreal_test\n# another\nsecond_test\n' > tl.txt
$ while IFS= read -r l; do
> case $l in ""|\#*) continue ;; esac
> echo "RUN $l"
> done < tl.txt
RUN real_test
RUN second_testCompare it with the alternative:
while IFS= read -r l; do
if [ -n "$l" ] && [ "${l:0:1}" != "#" ]; then
echo "RUN $l" # the real work, now indented inside a condition
fi
done < tl.txtBoth are correct. The first is better for the same reason an early return is better than a nested if: the guards are listed at the top, each on its own line, and the body's real work sits at the outer indentation level where it belongs. Chapter 17.2's [ -e "$f" ] || continue is the same pattern, and so is the whole top of Chapter 16.4's summarise.sh.
Stack them freely — one guard per line, each with a reason:
for f in ./*.log; do
[ -e "$f" ] || continue # the glob matched nothing
[ -f "$f" ] || continue # a directory named *.log
[ -s "$f" ] || continue # the tool died before writing
[ -r "$f" ] || continue # someone else's file
process "$f"
doneFive lines, five distinct reasons to skip, and the eye goes straight to process "$f".
3. break N and continue N
Both take an optional count saying how many enclosing loops to act on. Measured over a two-by-three nest:
$ for i in a b; do for j in 1 2 3; do [ "$j" -eq 2 ] && break; printf '%s%s ' "$i" "$j"; done; done; echo
a1 b1
$ for i in a b; do for j in 1 2 3; do [ "$j" -eq 2 ] && break 2; printf '%s%s ' "$i" "$j"; done; done; echo
a1
$ for i in a b; do for j in 1 2 3; do [ "$j" -eq 2 ] && continue 2; printf '%s%s ' "$i" "$j"; done; done; echo
a1 b1Read those three carefully, because the first and third have identical output for different reasons:
breakleft the inner loop only. The outer loop went on tob, which ran its own inner loop and also stopped atj=2. So:a1 b1.break 2left both loops. Nothing aftera1ran at all.continue 2skipped the rest of the outer iteration, which is the rest of the inner loop plus anything after it. Same output as plainbreakhere — and different the moment there is code after the inner loop:
$ for i in a b; do
> for j in 1 2 3; do [ "$j" -eq 2 ] && continue 2; printf '%s%s ' "$i" "$j"; done
> printf '[end-%s] ' "$i"
> done; echo
a1 b1
$ for i in a b; do
> for j in 1 2 3; do [ "$j" -eq 2 ] && break; printf '%s%s ' "$i" "$j"; done
> printf '[end-%s] ' "$i"
> done; echo
a1 [end-a] b1 [end-b]Now they differ: continue 2 skipped the [end-] line, break did not.
4. A Loop That Broke Reports Success
Here is the measurement that matters.
$ for i in 1 2 3; do [ "$i" -eq 2 ] && break; done; echo "status=$?"
status=0Fine — but so does this, where the body's real work failed on every pass:
$ for i in 1 2 3; do if [ "$i" -eq 2 ]; then break; fi; false; done; echo "status=$?"
status=0The last thing to run was break, which succeeded. A loop's exit status after break is 0, unconditionally.
So break cannot report an outcome, and the natural-looking search loop is wrong:
# WRONG — reports success whether or not it found anything
for f in "$@"; do
if grep -q "$pattern" "$f"; then break; fi
doneThat exits 0 for a match and 0 for no match. The caller cannot tell.
The fix is a flag, and a test after the loop:
$ found=0
$ for f in a b target d; do if [ "$f" = target ]; then found=1; break; fi; done
$ [ "$found" -eq 1 ]; echo "status=$? (0 means found)"
status=0 (0 means found)
$ found=0
$ for f in a b c d; do if [ "$f" = target ]; then found=1; break; fi; done
$ [ "$found" -eq 1 ]; echo "status=$? (1 means not found)"
status=1 (1 means not found)The bare [ "$found" -eq 1 ] on the last line is Chapter 16.7's ending: in a script it is the return value, with no exit needed.
This is the same lesson as Chapter 17.1 §8 — a loop's status tells you about its last iteration and nothing about its work — arriving from the other direction. Whenever a loop's outcome matters to the caller, carry it in a variable. break controls flow; it does not carry meaning.
5. A Complete Script
A search across a filelist, with every guard and a real answer at the end:
#!/usr/bin/env bash
# find-first.sh — report the first file in a list containing a pattern
# usage: find-first.sh <pattern> <filelist>
#
# exit: 0 found (path on stdout) · 1 not found · 2 unusable arguments
set -o nounset
pattern=${1:-}
list=${2:-}
if [ -z "$pattern" ] || [ -z "$list" ]; then
echo "usage: ${0##*/} <pattern> <filelist>" >&2
exit 2
fi
if ! [ -f "$list" ] || ! [ -r "$list" ]; then
echo "${0##*/}: $list is not a readable file" >&2
exit 2
fi
found=""
skipped=0
searched=0
while IFS= read -r -u 3 f || [ -n "$f" ]; do
# the filter guards: blank lines and comments are not files
case $f in ""|\#*) continue ;; esac
# a named file that is not usable is worth reporting, not hiding
if ! [ -f "$f" ]; then
printf '%s: no such file: %s\n' "${0##*/}" "$f" >&2
skipped=$(( skipped + 1 ))
continue
fi
if ! [ -r "$f" ]; then
printf '%s: not readable: %s\n' "${0##*/}" "$f" >&2
skipped=$(( skipped + 1 ))
continue
fi
searched=$(( searched + 1 ))
if grep -q -- "$pattern" "$f"; then
found=$f
break
fi
done 3< "$list"
if (( skipped > 0 )); then
printf '%s: %d of %d listed files were skipped\n' \
"${0##*/}" "$skipped" "$(( searched + skipped ))" >&2
fi
if [ -n "$found" ]; then
printf '%s\n' "$found"
exit 0
fi
printf '%s: no match in %d files\n' "${0##*/}" "$searched" >&2
exit 1$ printf 'UVM_ERROR : x\n' > a.log
$ printf 'TEST PASSED\n' > b.log
$ printf 'UVM_ERROR : y\n' > c.log
$ printf '# the logs\n\na.log\nb.log\nmissing.log\nc.log\n' > files.txt
$ ./find-first.sh TEST files.txt
b.log
$ echo $?
0
$ ./find-first.sh TEST files.txt 2>/dev/null
b.log
$ ./find-first.sh NEVERMATCH files.txt
find-first.sh: no such file: missing.log
find-first.sh: 1 of 4 listed files were skipped
find-first.sh: no match in 3 files
$ echo $?
1
$ ./find-first.sh UVM_ERROR files.txt
a.log
$ echo $?
0
$ ./find-first.sh TEST /nope
find-first.sh: /nope is not a readable file
$ echo $?
2Note what the TEST search did not print: it found b.log on the second file and broke, so it never reached missing.log and never reported the skip. That is correct — a search that succeeded has no business complaining about files it did not need to look at — and it is only correct because the skip message lives after the loop, guarded by a counter, rather than inside it.
Four decisions from this chapter:
case $f in ""|\#*) continue ;; esac filters the two kinds of line that are not files. Chapter 16.6's pattern branch, used as a guard, which is cheaper to read than two string tests.
A missing file is reported, not silently skipped. The §2 callout's rule: a continue for something that is legitimately not your business is silent; a continue for something wrong gets a line on stderr and a counter.
found=$f before break. The flag carries the answer and doubles as the found/not-found signal, so there is no separate boolean. [ -n "$found" ] after the loop is the real test — because §4 measured that the loop's own status is 0 regardless.
grep -q -- "$pattern" has -- before the pattern so a pattern beginning with a dash is a pattern and not an option — Chapter 1.5's convention, and the same defence Chapter 17.2 applied to file names.
Practice
- Run the
breakandcontinueloops over1 2 3 4 5and confirm1 2and1 2 4 5. Then move theprintfbefore the test in each and explain both new outputs. - Build the two-by-three nest and run plain
break,break 2andcontinue 2. Then add aprintf '[end-%s] 'after the inner loop and re-run all three. Which two were identical before and differ now? - Run
bash -c 'break'. What does the message tell you about wherebreakis resolved? - Write the wrong search loop from §4 —
if grep -q ...; then break; fiwith no flag — and show that it exits 0 for both a match and no match. - Fix it with a flag. Then make the script print the matching file's name as well, and say why one variable can do both jobs.
- Write a loop with five
continueguards over a hostile directory. Now add a counter to exactly one of them and justify which. - Take
find-first.shand move the skip-reportprintfinside the loop. Run theTESTsearch and describe what the user now sees that they should not. - Replace
find-first.sh'sbreakwithexit 0directly. What still works, what breaks, and what would break if this were a function instead of a script? Name the module that covers it.
Check Your Understanding
- What is the difference between
breakandcontinue? - In a two-deep nest, plain
breakandcontinue 2printed the same thing. Construct the one-line change that makes them differ. - What does the number in
break 2count? - A loop breaks out early. What is its exit status?
- Why can't
breakreport whether a search succeeded? - When should a
continuebe silent, and when should it print? - Why
grep -q -- "$pattern"rather thangrep -q "$pattern"?
6. Remember
breakleaves the loop;continuestarts the next iteration. Both abandon the rest of the body, and both work in every loop form.continueat the top is the filter idiom. One guard per line keeps the real work at the outer indentation level.- A silent
continuehides a problem. Skip quietly only what is legitimately not your business; otherwise print to stderr and count it. break Nandcontinue Ncount enclosing loops, notiforcaselevels.- Beyond
break 2, use a function. A numeric jump out of three loops is agoto. - A loop that broke reports status 0 — unconditionally, because
breakitself succeeded. - So
breakcannot report an outcome. Carry it in a variable and test it afterdone. - One variable can hold both the answer and the signal:
found=$f, then[ -n "$found" ]. - Report a skip after the loop, not inside it, so a successful early
breakdoes not complain about files it never needed.
Chapter 17.7 closes the module with the behaviour that makes experienced people doubt their own eyes: a counter that increments correctly on every pass and reads zero the moment the loop ends.
Where this fits
Part of the Linux & Bash curriculum.
