A loop is easy to write and easy to write wrongly. The forms below cover every case you will meet, and the failure modes are the same three repeated.
Topic 1: for — Iterating a List
for item in one two three; do
echo "$item"
done
The list is produced by the shell’s normal expansions, which is where both the power and the bugs come from.
Globs — the safe default:
for f in /var/log/*.log; do
echo "$f"
done
A glob never word-splits, so a filename containing spaces stays one item. This is the correct way to iterate files.
Enable nullglob so an empty directory runs zero iterations rather than once with the literal pattern:
shopt -s nullglob
for f in /empty/*.log; do echo "$f"; done # runs 0 times
Without it, $f is literally /empty/*.log — a file that does not exist, which then fails downstream in a confusing way.
Arrays:
for host in "${hosts[@]}"; do # quoted [@] -- element boundaries preserved
ssh -n "$host" uptime
done
Positional parameters:
for arg in "$@"; do echo "$arg"; done
for arg; do echo "$arg"; done # identical -- "in $@" is the default
Omitting the in clause iterates "$@" correctly, including quoting. It is the shortest safe form.
Ranges and sequences:
for i in {1..10}; do echo "$i"; done # brace expansion
for i in {0..20..5}; do echo "$i"; done # step of 5 → 0 5 10 15 20
for i in {a..e}; do echo "$i"; done # a b c d e
for i in $(seq 1 10); do echo "$i"; done # seq: forks, but takes variables
Brace expansion happens before parameter expansion, so {1..$n} does not work — it expands literally. Use seq "$n" or the C-style loop when the bound is a variable.
C-style:
for (( i = 0; i < 10; i++ )); do
echo "$i"
done
for (( i = ${#arr[@]} - 1; i >= 0; i-- )); do # reverse iteration
echo "${arr[i]}"
done
This is the form for counters and index arithmetic. Inside (( )) you write i, not $i — arithmetic context expands variables automatically.
Topic 2: while and until
while condition; do ... done # while it SUCCEEDS (exit 0)
until condition; do ... done # until it succeeds — the inverse
The condition is a command, not an expression. Its exit status is what the loop tests.
Reading input — the most common use:
while IFS= read -r line; do
printf '%s\n' "$line"
done < input.txt
Covered fully in the file-reading lesson; the pieces are IFS= to keep whitespace, -r to keep backslashes, and < file to avoid a subshell.
Polling with a bound:
attempts=0
until curl -fsS --max-time 5 "http://localhost:8080/health" >/dev/null 2>&1; do
(( attempts++ )) || true
if (( attempts >= 30 )); then
echo "service never became healthy" >&2
exit 1
fi
sleep 2
done
Always bound a polling loop. An until with no attempt counter is a script that hangs forever when the thing never comes up — and under cron, that is a job that never finishes and blocks every subsequent run.
Infinite loops, deliberately:
while true; do ...; done
while :; do ...; done # `:` is the null command — same effect
Topic 3: break and continue
for f in *.log; do
[[ -s $f ]] || continue # skip empty files
if (( $(stat -c %s "$f") > 100000000 )); then
echo "file too large, stopping: $f" >&2
break
fi
process "$f"
done
continueskips to the next iteration.breakexits the loop entirely.
Both take a level for nested loops, which is the part people do not know:
for dir in */; do
for f in "$dir"*.log; do
[[ -r $f ]] || continue 2 # skip the whole DIRECTORY, not just this file
done
done
break 2 and continue 2 operate on the enclosing loop. Far cleaner than a flag variable checked after the inner loop.
The guard-clause idiom:
for f in *.log; do
[[ -r $f ]] || continue
[[ -s $f ]] || continue
grep -q ERROR "$f" || continue
handle "$f"
done
Filtering with early continues keeps the body flat. The alternative — a nested if pyramid — is harder to read and easier to get wrong.
Topic 4: The Three Iteration Mistakes
1. Iterating command output with for:
for f in $(ls *.log); do ... done # WRONG
for f in $(find . -name '*.log'); do ... done # WRONG
Both split on whitespace, so error log.txt becomes two iterations. Use a glob, or while read with a null delimiter:
for f in ./*.log; do ... done
while IFS= read -r -d '' f; do ... done < <(find . -name '*.log' -print0)
2. The pipeline subshell:
count=0
find . -name '*.log' | while read -r f; do (( count++ )); done
echo "$count" # 0 — the loop ran in a subshell
Redirect or use process substitution instead:
while read -r f; do (( count++ )) || true; done < <(find . -name '*.log')
3. (( count++ )) under set -e:
set -e
count=0
(( count++ )) # returns exit status 1 because the VALUE is 0 → script exits
(( )) returns non-zero when the expression evaluates to zero, and post-increment yields the old value. Use (( count++ )) || true, (( ++count )), or count=$(( count + 1 )).
Topic 5: Loops You Should Not Write
Shell loops fork a process per iteration for anything that is not a builtin. Over thousands of items that dominates the runtime.
# ~10,000 forks
for f in *; do
basename "$f"
done
# zero forks
for f in *; do
echo "${f##*/}"
done
Often the loop should not exist at all:
| Instead of a loop over… | Use |
|---|---|
| files, running one command each | find ... -exec cmd {} + |
| files, in parallel | find ... -print0 | xargs -0 -P4 cmd |
| lines, extracting a field | awk '{print $2}' |
| lines, counting occurrences | sort | uniq -c |
| lines, substituting text | sed 's/a/b/g' |
# A loop calling gzip 5,000 times
for f in *.log; do gzip "$f"; done
# One process, batched arguments
find . -maxdepth 1 -name '*.log' -exec gzip {} +
The rule of thumb: if the loop body is a single external command, a tool probably already does it in one process. Reach for the loop when you need per-item logic, branching, or error handling.
Try it yourself: Create 2,000 files with touch file{1..2000}.txt, then time a loop calling basename against one using ${f##*/}. The difference is stark.
Common mistake: Writing for i in $(seq 1 $n) when for (( i=1; i<=n; i++ )) needs no subprocess and no quoting care. seq is fine for readability at small counts; the C-style loop is correct at any count.