Loops
for, while, until, and the pipeline that forgets
Three loop keywords cover almost everything: for walks a list you already have, while repeats
as long as a command keeps succeeding, and until is while with the test inverted.
for over a list
for env in dev staging prod; do
echo "deploying to $env"
done
deploying to dev
deploying to staging
deploying to prod
The list is words, split the way the shell splits any other words, which is why the loop
variable needs quoting when you use it and why an unquoted list is a trap. for does not know
about files, numbers or ranges; it iterates over whatever words it is given. A list whose items
contain spaces has to be an array, because there is no way to write it as
words and have the shell put it back together.
for over files
Give it a glob, not the output of ls:
for f in *.txt; do
echo "found $f"
done
found notes.txt
found report.txt
The shell expands *.txt into a sorted list of real filenames, one word each, spaces and all.
for f in $(ls *.txt) looks equivalent and is not: ls prints its results as text, the shell
then splits that text on whitespace, and a file called meeting notes.txt arrives as two
iterations. The glob never has that problem, so use it every time.
One thing to know: a glob that matches nothing is left as the literal pattern, so the loop body
runs once with f set to *.txt. Guard it when that matters:
for f in *.csv; do
[ -e "$f" ] || continue
echo "found $f"
done
echo "done"
done
Counting, when you need a number
for i in 1 2 3; do echo "attempt $i"; done
for i in {1..3}; do echo "attempt $i"; done
for ((i = 1; i <= 3; i++)); do echo "attempt $i"; done
All three print the same thing. {1..3} is brace expansion and cannot take a variable as its
bound, which is the usual reason people move to the C-style third form: {1..$n} does not
expand, while ((i <= n)) reads n normally.
while and reading a file line by line
while runs its body as long as its command succeeds. Paired with read, that is how you walk a
file:
while IFS= read -r line; do
echo "[$line]"
done < paths.list
[ /var/log/app.log]
[C:\temp\file]
[/srv/data]
IFS= and -r are both doing something, and leaving either off is the most common bug in
shell scripts that process text. Without them:
while read line; do
echo "[$line]"
done < paths.list
[/var/log/app.log]
[C:tempfile]
[/srv/data]
IFS= for that one command stops read from trimming leading and trailing whitespace, and -r
stops it treating a backslash as an escape character. The first line lost its indentation and
the second lost its backslashes entirely. Nothing warned, and the data is simply wrong.
Here is the difference, in one script:
count=0
printf 'a\nb\n' | while read -r line; do count=$((count + 1)); done
echo "after a pipe: $count"
count=0
while read -r line; do count=$((count + 1)); done < <(printf 'a\nb\n')
echo "after process substitution: $count"
after a pipe: 0
after process substitution: 2
until, break and continue
until repeats while its command fails, which suits waiting for something to come true:
n=1
until [ "$n" -gt 3 ]; do
echo "n is $n"
n=$((n + 1))
done
n is 1
n is 2
n is 3
break leaves the loop, continue skips to the next iteration. Both take a number to act on an
outer loop (break 2), which is occasionally the neat answer and more often a sign the inner
loop wants to be a function.
for f in notes.txt report.txt missing.txt; do
[ -e "$f" ] || continue
echo "processing $f"
done
processing notes.txt
processing report.txt
Exercises
-
Print every
.txtfile in the current directory with its line count, handling filenames containing spaces.Answer
for f in *.txt; do echo "$f: $(wc -l < "$f") lines" doneThe glob supplies each name as one word;
"$f"keeps it that way whenwcis called. Usingwc -l < "$f"rather thanwc -l "$f"stopswcprinting the filename too. -
Read
paths.listand count how many lines it has, using awhileloop, so that the count is still available after the loop.Answer
count=0 while IFS= read -r line; do count=$((count + 1)) done < paths.list echo "$count lines"Redirecting with
< paths.listkeeps the loop in the current shell. Pipingcat paths.listinto it would put the loop in a subshell andcountwould still be0. -
Why does
for i in {1..$n}not work, and what does it do instead?Answer
Brace expansion happens before variable expansion, so bash never sees a number to count to. The loop runs exactly once with
iset to the literal string{1..3}(or whatever$nexpands to). Usefor ((i = 1; i <= n; i++)), orseq.
What's next
Script arguments comes next: how a script reads what it was
called with, why "$@" and "$*" are not the same thing, and parsing real flags with
getopts.