Loops · about 40 min

for loops

Why this matters

The point of scripting is doing the same thing to many things: every log file, every user, every server, every argument. A for loop runs a block of commands once per item in a list, with a variable set to the current item. Combined with globs it is the everyday tool for batch work: convert every image, check every config file, rename a hundred files. Getting it right also depends on everything you learned about word splitting, which is where most loop bugs come from.

The shape

for NAME in WORD1 WORD2 WORD3; do
    COMMANDS     # run once per word, with $NAME set to it
done

bash expands the words after in (with all the usual expansions: variables, globs, braces, command substitution), producing a list. Then, for each item, it assigns the item to NAME and runs the body. do and done delimit the body; do can go on its own line, just like then.

$ for color in red green blue; do echo "I like $color"; done
I like red
I like green
I like blue

After the loop, the variable keeps its last value (echo $color prints blue). The loop's exit status is that of the last command run in the body.

Where the list comes from

Literal words and brace expansion

for server in web1 web2 db1; do echo "checking $server"; done
for n in {1..5}; do echo "attempt $n"; done
for d in {a..c}; do mkdir -p "test-$d"; done
for n in {0..20..5}; do echo $n; done          # 0 5 10 15 20

Brace expansion happens first and only works with literal numbers: {1..$n} does not expand $n (it produces the literal {1..5} when n=5), because braces are expanded before variables. For a range that comes from a variable, use seq or the C-style loop below.

Globs: looping over files

for f in *.log; do
    echo "$f: $(wc -l < "$f") lines"
done

Each file name matched by the glob becomes one item, even if it contains spaces, newlines or other strange characters. That is the key reason to loop over globs: the shell builds the list from the directory itself, not from text output. But remember to quote "$f" inside the body; the loop variable is safe, but using it unquoted in a command splits it again.

When nothing matches, the pattern stays as literal text (*.log), so the body runs once with a name that does not exist. Two defenses:

for f in *.log; do
    [[ -e $f ]] || continue       # skip the unmatched pattern (continue: lesson 4)
    ...
done

shopt -s nullglob                 # unmatched globs now expand to nothing
for f in *.log; do ...; done      # body simply does not run

"$@": looping over arguments

for arg in "$@"; do
    echo "argument: [$arg]"
done

"$@" expands to each argument as a separate word, exactly as given, so "two words" stays one item. Unquoted $@ or $* would split it. This loop is so common that for arg; do (with no in at all) means the same thing.

Command output, carefully

for user in $(cut -d: -f1 /etc/passwd); do echo "$user"; done

This works because user names contain no spaces. The unquoted $(...) is split on whitespace and globbed. For file names or anything that might contain spaces, do not do this; use a glob, find (module 14) or a while read loop (lesson 3).

The C-style for loop

bash also has a counting loop borrowed from C:

for (( i = 1; i <= 5; i++ )); do
    echo "line $i"
done

Inside (( )) are three arithmetic expressions: initialization (run once), condition (checked before each round; the loop stops when it is false) and step (run after each round). Because it is arithmetic, variables work where brace expansion would not:

n=${1:-3}
for (( i = n; i > 0; i-- )); do echo "$i..."; done
echo "liftoff"
$ bash countdown.sh 3
3...
2...
1...
liftoff

A worked example: a quick report

Open the shell for this lesson and try this on the shots directory created for the task (or any directory):

#!/bin/bash
# report.sh: summarize the files in a directory
dir=${1:-.}
count=0
bytes=0
for f in "$dir"/*; do
    [[ -f $f ]] || continue
    size=$(stat -c %s "$f")
    (( count += 1, bytes += size ))
    printf '%6d  %s\n' "$size" "${f##*/}"
done
echo "$count files, $bytes bytes"
$ bash report.sh shots
     8  Screenshot 1.png
     9  Screenshot 12.png
     8  Screenshot 2.png
...
7 files, 51 bytes

New pieces: "$dir"/* quotes the variable part and leaves the * unquoted so it still globs; stat -c %s prints a file's size in bytes; the comma in (( count += 1, bytes += size )) runs two expressions; printf '%6d %s\n' prints the number right-aligned in 6 columns; and ${f##*/} strips everything up to the last /, leaving the bare file name. Note the order: glob results are sorted as strings, so Screenshot 12 comes before Screenshot 2.

What the shell does with a loop

A for loop runs inside the current shell; no new process is created for the loop itself. bash reads the whole for ... done block first (that is why an interactive shell shows a > continuation prompt until you type done), expands the list once, and then executes the body repeatedly. Changing the list variable inside the loop does not change the iterations: the list was already built. External commands inside the body are forked and executed each round, so a loop that runs date 10,000 times starts 10,000 processes. Builtins and expansions are much cheaper; that is worth knowing when a loop feels slow.

Common mistakes

Exercises

how-many and no-match test the two classic surprises. args is checked with arguments that contain several spaces and a literal *, which only survive correct quoting. The rename task gives you a directory of files with spaces in their names; preview your loop with echo mv before running it for real.

Recap

Going further: help for. Try shopt -s nullglob dotglob and loop over * in a directory with hidden files to see what dotglob changes. Then time for i in {1..2000}; do /bin/true; done against for i in {1..2000}; do true; done with time to feel the cost of fork and exec.

Exercises

Exercise 1 · quiz

How many iterations?

/work/pics contains exactly two files: beach day.jpg and cat.jpg. How many times does the body of each loop run?

for f in pics/*.jpg; do echo "$f"; done          # loop A
files=$(ls pics)
for f in $files; do echo "$f"; done              # loop B
Answer
  • A: 2, B: 3

Loop A uses a glob directly in the for list. Globbing happens after word splitting, and each matching file name becomes exactly one word, spaces and all: 2 iterations. Loop B stores ls's output in a string, and the unquoted $files is word-split on whitespace, so beach day.jpg breaks into beach and day.jpg: 3 iterations, two of them with names that do not exist. Loop over globs, never over the output of ls.

Exercise 2 · quiz

When nothing matches

In an empty directory, what does for f in *.csv; do echo "got $f"; done print in bash with default settings?

Answer
  • got *.csv

By default, a glob that matches nothing is left as the literal text, so the loop runs once with f set to *.csv. Either check inside the loop ([[ -e $f ]] || continue) or turn on shopt -s nullglob, which makes a non-matching glob expand to nothing so the loop body never runs.

Exercise 3 · write code

Number the arguments

Write a script that prints each of its arguments on its own line, numbered from 1, in the form N: ARG. After the list, print total: N with the number of arguments.

For example, bash script.sh apple "two words" prints:

1: apple
2: two words
total: 2

With no arguments it prints just total: 0. Arguments with spaces must stay whole.

File: script.sh

Starting point

#!/bin/bash
for arg in $@; do
    echo "$arg"
done

Example with arguments apple two words

expected output

1: apple
2: two words
total: 2

Example with arguments a b * c

expected output

1: a  b
2: *
3: c
total: 3
Hint 1

$@ unquoted splits arguments that contain spaces. Use "$@".

Hint 2

Keep a counter: n=0 before the loop, (( n += 1 )) inside. $# is the argument count.

Reference solution
#!/bin/bash
n=0
for arg in "$@"; do
    (( n += 1 ))
    echo "$n: $arg"
done
echo "total: $#"

Exercise 4 · task in a shell

Rename a batch of files

/work/shots holds screenshots named like Screenshot 1.png, Screenshot 2.png, ... (with a space). Rename every one of them to shot-N.png (same number, for example Screenshot 7.png -> shot-7.png) using a for loop.

To get the number from a name, you can strip the fixed parts with parameter expansion: n=${f#shots/Screenshot } removes the prefix and n=${n%.png} removes the suffix. (These operators get a full lesson in module 15; here you only need these two lines.)

The other files in shots must not be touched.

Hint 1

Loop over shots/Screenshot*.png: the glob only matches the .png screenshots, and each name with its space is one word.

Hint 2

Inside: n=${f#shots/Screenshot }, n=${n%.png}, then mv "$f" "shots/shot-$n.png". Try it with echo mv ... first to preview.

Reference solution
for f in shots/Screenshot*.png; do
    n=${f#shots/Screenshot }
    n=${n%.png}
    mv "$f" "shots/shot-$n.png"
done