Bash & the POSIX Shell Module 5 – Loops
for loops: lists, globs, ranges and C-style
Loop over words, arrays, arguments and files with Bash's for, count with brace ranges, seq and the C-style form, and avoid the $(ls) and $(cat file) traps.
What you will learn
- Loop over words, arrays and a script's arguments with for, keeping each item whole
- Use globs to process the files of a pattern, including names with spaces and a pattern that matches nothing
- Write numeric loops with brace ranges, seq and the C-style form, and predict which of them dash refuses
- Replace loops over $(ls) and $(cat file) with globs and while read
- Predict what the loop variable holds after a loop, and keep it local inside a function
Before you start
What you’ll learn · Before you start
What you will learn
- Loop over words, arrays and a script's arguments with for, keeping each item whole
- Use globs to process the files of a pattern, including names with spaces and a pattern that matches nothing
- Write numeric loops with brace ranges, seq and the C-style form, and predict which of them dash refuses
- Replace loops over $(ls) and $(cat file) with globs and while read
- Predict what the loop variable holds after a loop, and keep it local inside a function
Before you start
On this page
In a folder of three files, for f in $(ls) ran its body four times. One name, June budget.xlsx, reached the loop
as two words, June and budget.xlsx, neither of them a file, while for f in * ran three times with every name
whole. Which of the two a script uses decides whether its loop is safe to point at real files.
You will loop over words, arrays and arguments, over files with globs, even when nothing matches, and over numbers
with brace ranges, seq and the C-style form, and see which of them dash refuses. On the way you will build a photo
renamer that shows what it would do first, print an aligned times table, and check what the loop variable holds
after the loop.
for runs its body once for each word of its list
for name in words; do commands; done first expands the words, with every expansion of the earlier lessons, then
runs the commands once for each word that is left, with name set to that word. The number of passes is fixed before
the first one starts, and quoting decides it. The session loops over a typed list, a variable whose value holds a
space, an array and the shell’s arguments:
from session import run
run("""
for w in alpha "beta gamma" delta; do printf '[%s]\\n' "$w"; done
city='New Delhi'
for w in "$city" $city end; do printf '[%s]\\n' "$w"; done
echo "after the loop: $w"
files=("June budget.xlsx" notes.txt)
for f in "${files[@]}"; do printf '[%s]\\n' "$f"; done
for f in ${files[@]}; do printf '[%s]\\n' "$f"; done
# set -- gives the shell three arguments, as a script receives them
set -- 'big file.txt' '' b.txt
for arg; do printf '<%s>\\n' "$arg"; done
for arg in $*; do printf '<%s>\\n' "$arg"; done
empty=
for x in $empty; do echo never; done; echo "status $?"
for x in a b; do false; done; echo "status $?"
""") Output
$ for w in alpha "beta gamma" delta; do printf '[%s]\n' "$w"; done
[alpha]
[beta gamma]
[delta]
$ city='New Delhi'
$ for w in "$city" $city end; do printf '[%s]\n' "$w"; done
[New Delhi]
[New]
[Delhi]
[end]
$ echo "after the loop: $w"
after the loop: end
$ files=("June budget.xlsx" notes.txt)
$ for f in "${files[@]}"; do printf '[%s]\n' "$f"; done
[June budget.xlsx]
[notes.txt]
$ for f in ${files[@]}; do printf '[%s]\n' "$f"; done
[June]
[budget.xlsx]
[notes.txt]
# set -- gives the shell three arguments, as a script receives them
$ set -- 'big file.txt' '' b.txt
$ for arg; do printf '<%s>\n' "$arg"; done
<big file.txt>
<>
<b.txt>
$ for arg in $*; do printf '<%s>\n' "$arg"; done
<big>
<file.txt>
<b.txt>
$ empty=
$ for x in $empty; do echo never; done; echo "status $?"
status 0
$ for x in a b; do false; done; echo "status $?"
status 1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 words_session.py
"beta gamma" was quoted, so it made one pass. With city='New Delhi', the list "$city" $city end gave four
passes: the quoted "$city" stayed whole, while the unquoted $city was
word splitting: The step in which Bash cuts the unquoted results of parameter expansion, command substitution and arithmetic expansion into separate words at the characters of IFS. More in “Word splitting and IFS: why "$var" needs quotes” at the space into New and Delhi. Step through that loop and watch the
loop variable: The name a for loop sets to each word of its list in turn. It is an ordinary shell variable: it keeps its last value after the loop and, inside a function, is shared with the caller unless declared local. w take one word per pass:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The quoted word. Bash expands the whole list once, before the first pass. "$city" is in double quotes, so its value stays one word, New Delhi, space and all.
- The unquoted word is split. The unquoted $city is split at the space into New and Delhi, and end is plain text. The list now holds four words, so the body will run four times.
- Pass 1. The first pass sets w to the first word and runs the body: printf prints [New Delhi], with the space inside the brackets.
- Pass 2. The second pass sets w to New. The body is the same; only the value of w has changed.
- Pass 3. The third pass takes Delhi, the second half of the split value.
- Pass 4. The fourth pass takes end, the last word of the list.
- No word left. No word is left, so the loop ends, with the status of the last command it ran, 0. w is an ordinary shell variable and still holds end, which the session printed after the loop.
An array works the same way: quoted, "${files[@]}" gave each element as one word, so June budget.xlsx made one
pass; unquoted, it made two.
A for with no in part loops over the positional parameter: An argument of a script or function, read by its position as $1, $2 and from the tenth on ${10}; $# counts them and "$@" passes them all on. More in “Positional and special parameters”, the arguments
of the script or function it is in: the Bash manual and POSIX both say that leaving out in words is the same as
writing in "$@". With the three arguments big file.txt, an empty one and b.txt, for arg; made three passes.
for arg in $* made three as well, but different ones: big, file.txt and b.txt, with the empty argument gone.
The positional parameters lesson compares "$@" and $* in full.
When the list expands to no words, as the empty $empty did, the body never runs and the loop ends with
exit status: The number from 0 to 255 every command ends with, kept by the shell in $?. 0 means success; Bash uses 126 for found but not executable and 127 for not found. More in “Your first script: shebang, chmod and running it” 0. Otherwise the for command takes over the status of whatever its body
ran last: for x in a b; do false; done ended with 1, the status of false.
An unquoted variable or array in the list
Wrong: for f in ${files[@]}, which made two passes out of June budget.xlsx. Right: for f in "${files[@]}",
and for arguments for arg; or for arg in "$@". Each item then makes exactly one pass, whatever characters it
holds.
Checkpoint
Check: One pass for each word of the list
A glob gives the loop whole file names, or itself when nothing matches
for f in *.txt is the safe way to visit files. Bash replaces the glob pattern: A word containing *, ? or [ that Bash replaces with the sorted names of the files it matches; it stays as written when nothing matches, unless nullglob or failglob is set. More in “Globbing: wildcards, dotfiles, extglob, globstar” with a
sorted list of the matching names after word splitting, so each name arrives as one word, spaces and all. The
session writes three files, one named to do.txt, and loops over the text files in Bash and, on its last line, in
dash:
from session import run, write
write("to do.txt", "buy milk\n")
write("notes.txt", "loops\n")
write("June budget.xlsx", "budget\n")
run("""
for f in *.txt; do printf '[%s]\\n' "$f"; done
!for f in *.txt; do wc -c $f; done
for f in *.txt; do wc -c -- "$f"; done
for f in *.csv; do printf '[%s]\\n' "$f"; done
for f in *.csv; do [[ -e $f ]] || continue; printf '[%s]\\n' "$f"; done; echo "status $?"
shopt -s nullglob
for f in *.csv; do printf '[%s]\\n' "$f"; done; echo "status $?"
shopt -u nullglob
dash -c 'for f in *.txt; do printf "[%s]\\n" "$f"; done'
""") Output
$ for f in *.txt; do printf '[%s]\n' "$f"; done
[notes.txt]
[to do.txt]
$ for f in *.txt; do wc -c $f; done
6 notes.txt
wc: to: open: No such file or directory
wc: do.txt: open: No such file or directory
0 total
[exit status 1]
$ for f in *.txt; do wc -c -- "$f"; done
6 notes.txt
9 to do.txt
$ for f in *.csv; do printf '[%s]\n' "$f"; done
[*.csv]
$ for f in *.csv; do [[ -e $f ]] || continue; printf '[%s]\n' "$f"; done; echo "status $?"
status 0
$ shopt -s nullglob
$ for f in *.csv; do printf '[%s]\n' "$f"; done; echo "status $?"
status 0
$ shopt -u nullglob
$ dash -c 'for f in *.txt; do printf "[%s]\n" "$f"; done'
[notes.txt]
[to do.txt]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 globs_session.py
Both shells made two passes, notes.txt and then to do.txt. The loop keeps each name whole in f, but the body
must keep it whole too: wc -c $f split to do.txt again and gave wc two names that do not exist, to and
do.txt, while wc -c -- "$f" counted both files. The -- ends the options, so even a name that starts with a dash
is read as a name.
A pattern that matches nothing needs a plan. By default Bash leaves it as written, so for f in *.csv made one pass
with f set to the text *.csv, which a command in the body would take for a file. The session shows two remedies.
[[ -e $f ]] || continue at the top of the body skips any word that is not an existing file (continue starts the
next pass, as the lesson on while, until and loop control explains). shopt -s nullglob makes a
pattern with no match expand to nothing, so the loop makes no pass. The guard works in any shell, written
[ -e "$f" ] || continue in a #!/bin/sh script, while the
A Bash option, switched on with shopt -s nullglob, that makes a glob pattern which matches no file expand to nothing instead of staying as written.
option is Bash’s own and stays on for every later pattern, which is why the session switched it off again; the
globbing lesson covers it and failglob.
rename-photos.sh puts these rules to work. It renames camera photos such as IMG_20250314_091522.jpg to names that
start with the date, here 2025-03-14_091522.jpg, reading the date from the old name with the =~ match of the
double-bracket lesson, never from the clock. Without --apply it only
prints what it would do. It skips names without a date and never replaces a file: it checks first, and mv -n
refuses to overwrite too:
#!/usr/bin/env bash
# rename-photos.sh: renames camera photos named IMG_YYYYMMDD_HHMMSS.jpg to YYYY-MM-DD_HHMMSS.jpg, taking the date
# from the file name itself. Without --apply it only prints what it would do. It never overwrites a file.
apply=no
if [[ ${1-} == --apply ]]; then
apply=yes
fi
re='^IMG_([0-9]{4})([0-9]{2})([0-9]{2})_([0-9]{6})(.*)\.jpg$'
seen=0
for f in IMG_*.jpg; do
[[ -e $f ]] || continue # no photo: the loop was given the pattern itself
seen=$((seen + 1))
if ! [[ $f =~ $re ]]; then
printf 'skip %s: no date in the name\n' "$f"
continue
fi
new="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]}_${BASH_REMATCH[4]}${BASH_REMATCH[5]}.jpg"
if [[ -e $new ]]; then
printf 'skip %s: %s already exists\n' "$f" "$new"
elif [[ $apply == yes ]]; then
mv -n -- "$f" "$new"
printf 'renamed %s -> %s\n' "$f" "$new"
else
printf 'would rename %s -> %s\n' "$f" "$new"
fi
done
if [[ $seen -eq 0 ]]; then
echo 'no IMG_*.jpg files here'
fi The session lets ShellCheck read the script, then runs it on a folder of five photos: a dry run, --apply, the dry
run again, and last an empty folder:
from session import files, run, write
files("rename-photos.sh")
for name in (
"IMG_20250314_091522.jpg",
"IMG_20250314_091522 (1).jpg",
"IMG_20250316_184005.jpg",
"2025-03-16_184005.jpg",
"IMG_0042.jpg",
):
write("photos/" + name, "")
run("""
shellcheck rename-photos.sh
cd photos
ls
bash ../rename-photos.sh
bash ../rename-photos.sh --apply
ls
bash ../rename-photos.sh
mkdir ../empty && cd ../empty
bash ../rename-photos.sh
""") Output
$ shellcheck rename-photos.sh $ cd photos $ ls 2025-03-16_184005.jpg IMG_0042.jpg IMG_20250314_091522 (1).jpg IMG_20250314_091522.jpg IMG_20250316_184005.jpg $ bash ../rename-photos.sh skip IMG_0042.jpg: no date in the name would rename IMG_20250314_091522 (1).jpg -> 2025-03-14_091522 (1).jpg would rename IMG_20250314_091522.jpg -> 2025-03-14_091522.jpg skip IMG_20250316_184005.jpg: 2025-03-16_184005.jpg already exists $ bash ../rename-photos.sh --apply skip IMG_0042.jpg: no date in the name renamed IMG_20250314_091522 (1).jpg -> 2025-03-14_091522 (1).jpg renamed IMG_20250314_091522.jpg -> 2025-03-14_091522.jpg skip IMG_20250316_184005.jpg: 2025-03-16_184005.jpg already exists $ ls 2025-03-14_091522 (1).jpg 2025-03-14_091522.jpg 2025-03-16_184005.jpg IMG_0042.jpg IMG_20250316_184005.jpg $ bash ../rename-photos.sh skip IMG_0042.jpg: no date in the name skip IMG_20250316_184005.jpg: 2025-03-16_184005.jpg already exists $ mkdir ../empty && cd ../empty $ bash ../rename-photos.sh no IMG_*.jpg files here
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rename_session.py
The dry run listed two renames and two skips: IMG_0042.jpg has no date in its name, and IMG_20250316_184005.jpg
would have replaced the file that already had its new name. IMG_20250314_091522 (1).jpg came through whole, because
the script only ever uses the quoted "$f". The next run found nothing left to rename, so a second run is harmless,
and in the empty folder the guard skipped the unmatched pattern and the count of zero printed the message.
ShellCheck reported nothing.
Key idea
Loop over files with a glob, quote "$f" everywhere in the body, and decide what the loop does when the pattern
matches nothing.
Checkpoint
Check: Globs and the pattern that matches nothing
Brace ranges need fixed numbers; the C-style form takes variables
Bash has three common ways to count. A brace expansion: The first expansion Bash performs: a comma list {a,b} or a range {1..9..2} in a word becomes several words, with the text around the braces copied into each. More in “Brace and tilde expansion” such as {1..5} writes the
numbers out as words before the loop starts, the C-style form counts with arithmetic, and seq, a separate program,
prints the numbers for a
command substitution: $(command), or the older backquoted form: Bash runs the command in a subshell and puts its standard output in place of the substitution, with every trailing newline removed. More in “Command and process substitution”
to hand to the loop. The session tries each, then gives the first two to dash:
from session import run
run("""
for i in {1..5}; do printf '%s ' "$i"; done; echo
echo {01..10}
echo {0..20..5}
n=4
echo {1..$n}
for ((i = 1; i <= n; i++)); do printf '%s ' "$i"; done; echo
for ((i = 0; i < 10; i += 3)); do printf '%s ' "$i"; done; echo
for i in $(seq 1 "$n"); do printf '%s ' "$i"; done; echo
for i in $(seq 0 5 20); do printf '%s ' "$i"; done; echo
type seq
dash -c 'for i in {1..3}; do echo "$i"; done'
!dash -c 'for ((i = 1; i <= 3; i++)); do echo "$i"; done'
""") Output
$ for i in {1..5}; do printf '%s ' "$i"; done; echo
1 2 3 4 5
$ echo {01..10}
01 02 03 04 05 06 07 08 09 10
$ echo {0..20..5}
0 5 10 15 20
$ n=4
$ echo {1..$n}
{1..4}
$ for ((i = 1; i <= n; i++)); do printf '%s ' "$i"; done; echo
1 2 3 4
$ for ((i = 0; i < 10; i += 3)); do printf '%s ' "$i"; done; echo
0 3 6 9
$ for i in $(seq 1 "$n"); do printf '%s ' "$i"; done; echo
1 2 3 4
$ for i in $(seq 0 5 20); do printf '%s ' "$i"; done; echo
0 5 10 15 20
$ type seq
seq is /usr/bin/seq
$ dash -c 'for i in {1..3}; do echo "$i"; done'
{1..3}
$ dash -c 'for ((i = 1; i <= 3; i++)); do echo "$i"; done'
dash: 1: Syntax error: Bad for loop variable
[exit status 2]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ranges_session.py
{1..5} gave 1 to 5, {01..10} padded every number to two digits because a bound starts with a zero, and
{0..20..5} counted in fives. Brace expansion comes before any variable is read, so {1..$n} was no range at that
moment and stayed as the text {1..4}, as the brace expansion lesson
explains. A brace range is for bounds you can type.
When a bound is in a variable, use the C-style form, an arithmetic for loop: Bash's C-style loop, for (( start; test; update )): the start runs once, the test before every pass and the update after it, and the loop goes on while the test is not zero. POSIX sh does not have it.:
for ((i = 1; i <= n; i++)). The first expression runs once, before the first pass; the second is tested before
every pass, and the loop goes on while it is not zero; the third runs after every pass. Variables need no $ there,
and any step works: i += 3 gave 0, 3, 6 and 9. seq gets the same numbers from outside the shell: $(seq 1 "$n")
printed 1 to 4 and $(seq 0 5 20) counted in fives. It is a program, /usr/bin/seq here, and not one of the
utilities that POSIX.1-2024 lists, although most systems have it.
dash ran the last two lines. It printed {1..3} as one word, because POSIX sh has no brace expansion, and refused the
C-style loop: Syntax error: Bad for loop variable, exit status 2. The table sums up the three forms:
| Form | Use it for | In POSIX sh (dash) |
|---|---|---|
{1..10}, {01..12} |
typed bounds, zero padding | no: printed as written |
for ((i = 1; i <= n; i++)) |
bounds in variables, any step | no: a syntax error |
$(seq 1 "$n") |
a list from a program | the loop is; seq is not POSIX |
Loops nest. times-table.sh prints an n by n table with two C-style loops, rows outside and columns inside, and
printf '%4d' right-aligns each product in four characters, so the columns line up:
#!/usr/bin/env bash
# times-table.sh: prints an n by n multiplication table with two nested C-style loops (n is the first argument, 5 if
# none is given). printf '%4d' right-aligns every product in a column four characters wide.
n=${1:-5}
for ((row = 1; row <= n; row++)); do
for ((col = 1; col <= n; col++)); do
printf '%4d' $((row * col))
done
printf '\n'
done The session prints the table for 4 and for 12, then hands the script to ShellCheck:
from session import files, run
files("times-table.sh")
run("""
bash times-table.sh 4
bash times-table.sh 12
shellcheck times-table.sh
""") Output
$ bash times-table.sh 4 1 2 3 4 2 4 6 8 3 6 9 12 4 8 12 16 $ bash times-table.sh 12 1 2 3 4 5 6 7 8 9 10 11 12 2 4 6 8 10 12 14 16 18 20 22 24 3 6 9 12 15 18 21 24 27 30 33 36 4 8 12 16 20 24 28 32 36 40 44 48 5 10 15 20 25 30 35 40 45 50 55 60 6 12 18 24 30 36 42 48 54 60 66 72 7 14 21 28 35 42 49 56 63 70 77 84 8 16 24 32 40 48 56 64 72 80 88 96 9 18 27 36 45 54 63 72 81 90 99 108 10 20 30 40 50 60 70 80 90 100 110 120 11 22 33 44 55 66 77 88 99 110 121 132 12 24 36 48 60 72 84 96 108 120 132 144 $ shellcheck times-table.sh
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 times_session.py
The inner loop ran n times for each pass of the outer one, 16 products for 4 and 144 for 12, and printf '\n' ended
each row. Each product took exactly four characters, 144 and 1 alike, so each row for 12 is 48 characters long.
ShellCheck reported nothing: the script asks for Bash on its first line, and both loops are at home there.
Key idea
Type a brace range when the bounds are fixed, use for (( … )) when a bound is in a variable, and keep both out of
scripts that sh must run.
Checkpoint
Check: Brace ranges, seq and the C-style loop
Looping over $(ls) or $(cat file) cuts names and lines into words
Both look like ways to get a list, and both give the loop words. What ls prints is plain text, and an unquoted
command substitution goes through the same two steps as an unquoted variable: Bash splits it at the characters of
IFS: The internal field separator: the characters Bash splits unquoted expansions on and read splits lines on; by default a space, a tab and a newline. More in “Word splitting and IFS: why "$var" needs quotes”, by default space, tab and newline, then expands every word that looks like a glob pattern.
The session runs both loops beside their fixes, in a folder holding June budget.xlsx, notes.txt and todo.txt:
from session import run, write
write("todo.txt", "* call the bank\n* renew passport\n")
run("""
touch 'June budget.xlsx' notes.txt
for f in $(ls); do printf '[%s]\\n' "$f"; done
for f in "$(ls)"; do printf '[%s]\\n' "$f"; done
for f in *; do printf '[%s]\\n' "$f"; done
cat todo.txt
for line in $(cat todo.txt); do printf '[%s]\\n' "$line"; done
while IFS= read -r line; do printf '[%s]\\n' "$line"; done < todo.txt
!printf '%s\\n' 'for f in $(ls); do echo "$f"; done' | shellcheck -s bash -
!printf '%s\\n' 'for line in $(cat todo.txt); do echo "$line"; done' | shellcheck -s bash -
""") Output
$ touch 'June budget.xlsx' notes.txt
$ for f in $(ls); do printf '[%s]\n' "$f"; done
[June]
[budget.xlsx]
[notes.txt]
[todo.txt]
$ for f in "$(ls)"; do printf '[%s]\n' "$f"; done
[June budget.xlsx
notes.txt
todo.txt]
$ for f in *; do printf '[%s]\n' "$f"; done
[June budget.xlsx]
[notes.txt]
[todo.txt]
$ cat todo.txt
* call the bank
* renew passport
$ for line in $(cat todo.txt); do printf '[%s]\n' "$line"; done
[June budget.xlsx]
[notes.txt]
[todo.txt]
[call]
[the]
[bank]
[June budget.xlsx]
[notes.txt]
[todo.txt]
[renew]
[passport]
$ while IFS= read -r line; do printf '[%s]\n' "$line"; done < todo.txt
[* call the bank]
[* renew passport]
$ printf '%s\n' 'for f in $(ls); do echo "$f"; done' | shellcheck -s bash -
In - line 1:
for f in $(ls); do echo "$f"; done
^---^ SC2045 (error): Iterating over ls output is fragile. Use globs.
For more information:
https://www.shellcheck.net/wiki/SC2045 -- Iterating over ls output is fragi...
[exit status 1]
$ printf '%s\n' 'for line in $(cat todo.txt); do echo "$line"; done' | shellcheck -s bash -
In - line 1:
for line in $(cat todo.txt); do echo "$line"; done
^-------------^ SC2013 (info): To read lines rather than words, pipe/redirect to a 'while read' loop.
For more information:
https://www.shellcheck.net/wiki/SC2013 -- To read lines rather than words, ...
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 traps_session.py
Step through what happened to the three names on their way into the first loop:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Three files. The folder holds three files, and the name June budget.xlsx has a space in it.
- ls prints text. $(ls) runs ls and takes what it printed, the three names one per line, as one piece of text. Nothing in that text says where a name starts or ends.
- The text is split. The substitution is unquoted, so Bash splits the text at spaces, tabs and newlines, the characters of IFS. June budget.xlsx falls apart into June and budget.xlsx.
- Four passes for three files. The loop runs four times. The first two passes get June and budget.xlsx, names that do not exist, so a command in the body would be handed two files that are not there. The words would also be read as glob patterns: none here has a *, ? or [.
- A glob reads the folder. for f in * asks Bash for the names that match, and each match becomes exactly one word, never split. The loop runs three times, once per file, with every name whole.
for f in $(ls) made four passes for three files, and two of its words, June and budget.xlsx, name no file.
Double quotes are no cure: "$(ls)" made a single pass, one word holding all three names and the newlines between
them. The glob * made three passes with whole names. ShellCheck reports the $(ls) loop as SC2045, an error whose
message is the advice: “Iterating over ls output is fragile. Use globs.”
$(cat todo.txt) breaks the same way, and worse. Both lines of the to-do list start with *, and after the split
that star was a glob pattern of its own, which became the three file names. Two lines made eleven passes: the three
names twice, and the other words one at a time. ShellCheck’s SC2013 names the fix, a while read loop, and the
session’s read loop gave back both lines exactly as written.
Reading files line by line, the third lesson of this module, takes
while IFS= read -r line apart.
Key idea
A loop over $(…) receives words, not names or lines: loop over files with a glob and over lines with while read.
Checkpoint
Check: Why a loop never reads $(ls) or $(cat file)
The loop variable keeps its last value, and it is not local
The loop variable is an ordinary shell variable: A name that holds a value inside the shell, set with name=value and read with $name. Bash keeps every value as a string unless the variable has an attribute. More in “Variables, export and the environment”: the loop sets it, and nothing unsets it afterwards. The session looks at what four loops leave behind:
from session import run
run("""
for f in a.txt b.txt; do :; done; echo "f=$f"
for ((i = 1; i <= 3; i++)); do :; done; echo "i=$i"
shopt -s nullglob
last=none; for last in *.csv; do :; done; echo "last=$last"
shopt -u nullglob
tidy() { for f in x.log y.log; do :; done; }
f=budget.xlsx; tidy; echo "f=$f"
tidy_local() { local f; for f in x.log y.log; do :; done; }
f=budget.xlsx; tidy_local; echo "f=$f"
""") Output
$ for f in a.txt b.txt; do :; done; echo "f=$f"
f=b.txt
$ for ((i = 1; i <= 3; i++)); do :; done; echo "i=$i"
i=4
$ shopt -s nullglob
$ last=none; for last in *.csv; do :; done; echo "last=$last"
last=none
$ shopt -u nullglob
$ tidy() { for f in x.log y.log; do :; done; }
$ f=budget.xlsx; tidy; echo "f=$f"
f=y.log
$ tidy_local() { local f; for f in x.log y.log; do :; done; }
$ f=budget.xlsx; tidy_local; echo "f=$f"
f=budget.xlsx
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 after_session.py
After for f in a.txt b.txt, f held the last word, b.txt, as w held end in the first session. The C-style
loop left i at 4, not 3: after the last pass, i++ made it 4, the test i <= 3 failed, and the loop stopped one
step past the last value its body saw. A loop that makes no pass never sets its variable, so with nullglob on and no
.csv file, last still said none: give a variable a value before a loop that might not run.
Functions add a trap. The Bash manual says that variables are normally shared between a function and its caller, so
tidy, which loops with f, replaced the caller’s f=budget.xlsx with y.log. tidy_local starts with local f,
which keeps its f inside the function, and the caller’s budget.xlsx survived. Functions get a module of their own
later in the track; the habit to take from here is to declare a function’s loop variables local.
Checkpoint
Check: The loop variable after the loop
Practise: loops over words, files and numbers
Each question takes a list, a folder or a script from the sessions and changes one thing: a name, a bound, a shell. Decide what the loop does before you check; each explanation points to the run that shows it:
Practice that adapts to you
Practise this lesson
20 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): a for loop written for arg; do … done has no in. What does it loop over?
It loops over the positional parameters, the arguments of the script or of the function it is in, exactly as
for arg in "$@" would: one pass per argument, each kept whole, empty ones included. The Bash manual and POSIX define
the short form that way, so dash accepts it too. for arg in $*, or $@ without quotes, is a different loop: it
splits each argument at spaces and drops empty ones, so big file.txt made two passes and the empty argument none.
Write the short form or the quoted "$@".
Two longer questions follow, each a job that fails in a way you must explain before you fix it. Work out your first step for each, then compare it with the one given:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A nightly job runs mv incoming/.csv archive/. On a day when 80,000 reports arrive, it stops with "Argument list too long" and moves nothing. A teammate's rewrite, for f in $(ls incoming/.csv); do mv "$f" archive/; done, ends with status 0 but leaves every report where it was. Explain both runs and write the job properly.
-
Fresher to mid
A #!/bin/sh job makes monthly folders with for m in {01..12}; do mkdir -p "reports/$m"; done and then loops over days with for ((d = 1; d <= 31; d++)). In a container whose sh is dash, it leaves one folder named {01..12} and stops with a syntax error. Explain both symptoms and rewrite the loops for POSIX sh.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How does for name in words decide how many passes it makes?
It expands the words once, then runs the body once for each word left, so quoting decides the passes: "$var" and "${array[@]}" keep every item whole.
-
What does a for loop written for arg; do ... done loop over?
The positional parameters, exactly as for arg in "$@" would: one pass per argument, each kept whole, empty ones included. POSIX defines the short form, so dash accepts it too.
-
Why is a glob the right list for a loop over files?
It gives the loop each matching name as one sorted word that is never split, even when the name holds spaces; quote "$f" wherever the body uses it.
-
What does for f in *.csv do in a folder with no CSV files, and how is it handled?
It makes one pass with f set to the pattern itself, *.csv. Skip that pass with [[ -e $f ]] || continue, or turn on shopt -s nullglob so the loop does not run at all.
-
When do you use a brace range and when the C-style for (( ))?
A brace range such as {1..10} needs bounds typed into the script. The C-style for (( i = 1; i <= n; i++ )) takes variables and any step. dash prints brace ranges as written and refuses the C-style form.
-
Is seq part of the shell?
No. seq is an outside program, and POSIX does not define it, so a loop that relies on it depends on the system having it.
-
Why are for f in $(ls) and for line in $(cat file) wrong?
The output is split at spaces, tabs and newlines and then globbed, so names and lines break into words. Loop over files with a glob and over lines with while read, as ShellCheck's SC2045 and SC2013 advise.
-
What does the loop variable hold after a for loop ends?
Its last value, and in the C-style form one step past the last pass. A for ... in loop that makes no pass never sets it, and inside a function it should be declared local.
Key takeaways
for name in wordsexpands the words once and runs the body for each word left, so quoting decides the passes:"$var"and"${array[@]}"keep items whole, andfor arg;meansfor arg in "$@".- A glob gives the loop each matching name as one sorted, unsplit word; quote
"$f"in the body, and handle a pattern that matches nothing with[[ -e $f ]] || continueorshopt -s nullglob. - Brace ranges need typed bounds, the C-style
for (( … ))takes variables and any step, andseqis an outside program that POSIX does not define; dash prints brace ranges as written and refuses the C-style form. $(ls)and$(cat file)are split at spaces, tabs and newlines and then globbed, so loop over files with a glob and over lines withwhile read, as ShellCheck’s SC2045 and SC2013 advise.- The loop variable keeps its last value, one step past the last pass in the C-style form; a
for … inloop that makes no pass never sets it, and inside a function it should belocal.
Terms in this lesson
- arithmetic for loop
- Bash's C-style loop, for (( start; test; update )): the start runs once, the test before every pass and the update after it, and the loop goes on while the test is not zero. POSIX sh does not have it.
- loop variable
- The name a for loop sets to each word of its list in turn. It is an ordinary shell variable: it keeps its last value after the loop and, inside a function, is shared with the caller unless declared local.
References
- Bash Reference Manual: Looping Constructs (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Brace Expansion (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Filename Expansion (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: The Shopt Builtin (nullglob) (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Shell Functions (Chet Ramey (GNU Bash maintainer))
- POSIX.1-2024: Shell Command Language (2.9.4.2 The for Loop) (The Open Group and IEEE)
- POSIX.1-2024: Shell Command Language (2.6 Word Expansions) (The Open Group and IEEE)
- POSIX.1-2024: Utilities (the list of standard utilities) (The Open Group and IEEE)
- POSIX.1-2024: Utility Syntax Guidelines (Guideline 10, the -- argument) (The Open Group and IEEE)
- GNU Coreutils manual: mv invocation (-n, --no-clobber) (GNU Project)
- ShellCheck wiki: SC2045 (ShellCheck)
- ShellCheck wiki: SC2013 (ShellCheck)
Report a problem with this lesson
Kept only in this browser. Your Learn progress