Loops in Shell — for, while, until
Loops are where Bash’s word-splitting behavior — something you’ve now hit twice, with touch in Chapter 2 and [ ] in Chapter 7 — causes the most damage in real scripts. for file in $(ls) looks completely reasonable and works fine in every quick test, right up until a filename contains a space and the loop silently processes the wrong things entirely. This chapter covers all three loop forms, break/continue, and the safe patterns for iterating over files and command output — including exactly why that ls pattern is a trap.
The Three Loop Forms: for, while, until
for — Iterating Over A List
for fruit in apple banana cherry; do
echo "Fruit: $fruit"
doneFruit: apple
Fruit: banana
Fruit: cherryfor runs its body once per item in the list, assigning each item to the loop variable in turn. The list can also come from glob expansion — matching filenames directly, one per iteration:
for file in *.md; do
echo "Found: $file"
doneNote
If no files match the pattern, Bash’s default behavior is to run the loop once anyway, with the loop variable set to the literal, unmatched pattern text (*.md) — not zero times, as you might expect. A shell option covered later in the course changes this default; for now, be aware that an empty-looking directory can still trigger one unexpected iteration.
C-Style for
For counting loops, Bash also supports a C-style form using the (( )) arithmetic context:
for (( i=0; i<5; i++ )); do
echo "i is $i"
donei is 0
i is 1
i is 2
i is 3
i is 4The three parts — initialization, condition, and increment — work exactly as they do in C-family languages, separated by ;.
while — Loop While A Condition Holds
count=1
while [[ "$count" -le 5 ]]; do
echo "Count: $count"
(( count++ ))
doneCount: 1
Count: 2
Count: 3
Count: 4
Count: 5while re-checks its condition — any command, exactly as with if back in Chapter 7 — before each iteration, and keeps looping as long as that command succeeds (exit status 0).
until — Loop Until A Condition Holds
until is while’s mirror image: it loops as long as its condition fails, stopping the moment it succeeds.
count=1
until [[ "$count" -gt 5 ]]; do
echo "Count: $count"
(( count++ ))
doneCount: 1
Count: 2
Count: 3
Count: 4
Count: 5Same output as the while version above — until [[ "$count" -gt 5 ]] and while [[ "$count" -le 5 ]] express the same stopping point from opposite directions. Which one reads more naturally depends on the situation; until service_is_running; do sleep 1; done often reads more clearly than the equivalent negated while.
break And continue
break exits a loop immediately; continue skips straight to the next iteration without running the rest of the current one:
for n in 1 2 3 4 5 6 7 8; do
if [[ "$n" -eq 6 ]]; then
break
fi
if (( n % 2 == 0 )); then
continue
fi
echo "Odd number: $n"
doneOdd number: 1
Odd number: 3
Odd number: 5The loop stops entirely once n reaches 6, and even numbers before that point are skipped via continue without ever reaching the echo.
IFS And Field Splitting
IFS (Internal Field Splitting) is the variable that controls where Bash splits unquoted text into separate words — including inside a for loop’s list and, critically, inside read, covered next. Its default value is space, tab, and newline:
printf '%s\n' "$IFS" | cat -A ^I$
$That default is exactly why for word in $unquoted_variable splits on whitespace — it’s not special for behavior, it’s IFS doing its normal job on an unquoted expansion. Temporarily changing IFS changes what counts as a separator, which becomes directly relevant in the next section.
Reading Lines Safely With while read
read reads one line of input into a variable — you’ll cover it fully, with all its options, in a later chapter, but the safe file-reading pattern is worth knowing now, since it’s the correct alternative to a genuinely common mistake:
while IFS= read -r line; do
echo "Line: [$line]"
done < input.txtTwo details in that line matter a lot:
IFS=(set to empty, immediately beforeread, scoped to just that command) preventsreadfrom trimming leading and trailing whitespace off each line — without it, a line like" indented"would silently lose its leading spaces.-rtellsreadto treat backslashes literally, rather than interpreting them as escape characters. Without-r, a line containing\nor a trailing\gets mangled instead of read as-is.
This while IFS= read -r line; do ... done < file pattern is the standard, safe way to process a file line by line in Bash — each line becomes exactly one loop iteration, regardless of how many spaces it contains, because read splits on newlines here, not on whitespace within a line.
The Classic Pitfall: Looping Over Command Output
Here’s the mistake this chapter opened with, in full:
for file in $(ls); do
echo "Processing: $file"
doneThis looks completely reasonable, and passes every quick test against filenames with no spaces in them. The problem is that $(ls) is an unquoted command substitution — its output gets word-split by IFS exactly like any other unquoted expansion, meaning a single filename containing a space gets torn into two separate loop iterations, each treating half a filename as if it were a whole one.
The fix, covered directly in this chapter’s Shell-Safety Considerations section below, is to let for iterate over a glob pattern directly instead of parsing command output at all — for file in *; do — since glob expansion in a for list produces each matched filename as one complete word, spaces and all, with no IFS splitting involved.
Best Practices
- Never loop over
$(ls)or any other unquoted command substitution to get a file list. Use a glob (for file in *) for files in a single directory, orwhile IFS= read -r linefor arbitrary line-based input. - Always quote the loop variable inside the body —
"$file", not$file— for the same word-splitting reasons covered since Chapter 2, even though the loop’s own iteration was done safely. - Use
while IFS= read -r lineas your default for reading files line by line — treat the plainIFS=/-rcombination as a fixed pattern to reach for, not something to reconstruct each time. - Reach for C-style
forwhen you need a numeric counter, and plainfor ... in listwhen you already have (or can glob) the exact set of items to iterate.
Shell-Safety Considerations
Here’s the $(ls) word-splitting bug from above, made concrete with an actual space-containing filename.
Naive (broken) version:
mkdir -p /tmp/shell-course-demo
cd /tmp/shell-course-demo
touch "my report.txt" "notes.txt"
for file in $(ls); do
echo "Processing: $file"
doneProcessing: my
Processing: report.txt
Processing: notes.txtThree iterations for two actual files. "my report.txt" was split into my and report.txt as separate words — and any real per-file logic in that loop body (renaming, deleting, uploading) would now silently operate on two nonexistent files, my and report.txt, instead of the one real file that actually needed processing.
Corrected version — glob the directory directly instead of parsing ls:
for file in *; do
echo "Processing: $file"
doneProcessing: my report.txt
Processing: notes.txtTwo iterations for two actual files, exactly as intended — the space inside "my report.txt" stayed intact, because glob expansion in a for list hands each match to the loop as one complete word, with no IFS-based splitting applied afterward.
Hands-On: Loop Forms And Safe File Iteration
1. Set up a working directory.
mkdir -p ~/shell-course/ch8
cd ~/shell-course/ch82. Run each loop form once.
for color in red green blue; do
echo "Color: $color"
done
for (( i=1; i<=3; i++ )); do
echo "i=$i"
done
n=1
while [[ "$n" -le 3 ]]; do
echo "while n=$n"
(( n++ ))
done
n=1
until [[ "$n" -gt 3 ]]; do
echo "until n=$n"
(( n++ ))
done3. Try break and continue together.
for n in 1 2 3 4 5 6 7 8; do
if [[ "$n" -eq 6 ]]; then
break
fi
if (( n % 2 == 0 )); then
continue
fi
echo "Odd number: $n"
done4. Reproduce the $(ls) word-splitting bug in an isolated folder, then fix it.
mkdir -p /tmp/shell-course-demo
cd /tmp/shell-course-demo
touch "my report.txt" "notes.txt"
echo "--- broken: \$(ls) ---"
for file in $(ls); do
echo "Processing: $file"
done
echo "--- fixed: glob ---"
for file in *; do
echo "Processing: $file"
done5. Read a file safely line by line, including a line with leading whitespace.
cd ~/shell-course/ch8
printf 'first line\n indented line\nlast line\n' > input.txt
while IFS= read -r line; do
echo "Line: [$line]"
done < input.txtLine: [first line]
Line: [ indented line]
Line: [last line]Confirm the leading spaces on the second line survived intact — rerun without IFS= to see them silently stripped, as a demonstration of exactly what that piece of the pattern is protecting.
6. Clean up.
cd ~
rm -rf ~/shell-course/ch8
rm -rf /tmp/shell-course-demoYou’ve now run all three loop forms, controlled iteration with break and continue, and — most importantly — reproduced the exact bug that makes $(ls) in a loop dangerous, then fixed it two different ways depending on whether you’re iterating over files or arbitrary line-based input. The next chapter covers arrays: storing multiple values in a single variable, and iterating over them properly.