Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 10 minutes
  • Examples run with Bash 5.3.20, dash 0.5.13.5, shellcheck 0.11.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

A typed list, a split variable, an array and the arguments Python · words_session.py
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:

Interactive · step by step One pass of the body for each word of the list Step through the loop over "$city" $city end with city set to New Delhi. The words are on the left; the loop variable and the line printed are on the right.
One pass of the body for each word of the list, step 7 of 7. Showing New Delhi, New, Delhi, end, the variable w (still end), printed ([end]). In focus: the variable w.New DelhiNewDelhiendthe variable wstill endprinted[end]

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. Pass 2. The second pass sets w to New. The body is the same; only the value of w has changed.
  5. Pass 3. The third pass takes Delhi, the second half of the split value.
  6. Pass 4. The fourth pass takes end, the last word of the list.
  7. 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

  1. Question 1 of 2 With place='Navi Mumbai', how many lines does for p in "$place" $place; do echo "$p"; done print?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 3

    The quoted "$place" stays one word and the unquoted $place splits into Navi and Mumbai, so the list holds three words and the body runs three times. The session's "$city" $city end gave four passes the same way.

  2. Question 2 of 2 list.sh holds for arg; do echo "[$arg]"; done. What does bash list.sh 'a b' c print?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: [a b], then [c]

    The Bash manual and POSIX both define for arg; as for arg in "$@". The session's three arguments made three passes, the empty one included.

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:

Whole names from a glob, an unquoted body, and a pattern with no match Python · globs_session.py
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:

rename-photos.sh: dated names, a dry run first, no overwriting Shell · rename-photos.sh
#!/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:

rename-photos.sh: the dry run, the renames, a second run and an empty folder Python · rename_session.py
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

  1. Question 1 of 2 A folder has no .log files. What does for f in *.log; do echo "found $f"; done print in Bash with the default settings?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: found *.log

    The session's for f in .csv made the same single pass and printed [.csv]. Guard the body with [[ -e $f ]] || continue, or set nullglob before the loop.

  2. Question 2 of 2 A script that starts with #!/bin/sh loops with for f in *.log and must also run under dash. Which first line of the body skips the unmatched pattern?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: [ -e "$f" ] || continue

    The guard skips every word that is not an existing file, and when nothing matches, the only word left is the pattern itself.

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:

Brace ranges, the C-style form and seq, and dash on the first two Python · ranges_session.py
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:

times-table.sh: two nested C-style loops Shell · times-table.sh
#!/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:

times-table.sh for 4 and for 12, and ShellCheck Python · times_session.py
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

  1. Question 1 of 2 dash runs for i in {1..3}; do echo "$i"; done. What does it print?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: {1..3}, once

    Brace ranges are a Bash feature. Under a shell without them, the range quietly becomes one literal word.

  2. Question 2 of 2 What does for ((i = 10; i > 0; i -= 4)); do printf '%s ' "$i"; done print?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 10 6 2

    The first expression runs once, the test before every pass and the third expression after every pass: the session's i += 3 gave 0, 3, 6 and 9 in the same way.

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:

$(ls) and $(cat file) beside a glob and a read loop, and what ShellCheck says Python · traps_session.py
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:

Interactive · step by step What $(ls) gives a loop, and what a glob gives it Step through the two loops of the session. The files of the folder are on the left; the words each loop receives are on the right.
What $(ls) gives a loop, and what a glob gives it, step 5 of 5. Showing June budget.xlsx (one word), notes.txt (one word), todo.txt (one word). In focus: June budget.xlsx, notes.txt, todo.txt.June budget.xlsxone wordnotes.txtone wordtodo.txtone word

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Three files. The folder holds three files, and the name June budget.xlsx has a space in it.
  2. 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.
  3. 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.
  4. 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 [.
  5. 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)

  1. One question A folder holds Q1 sales.csv and Q2 sales.csv and nothing else. How many passes does for f in $(ls) make?

    Type a number.

    Show the answer: One question

    Answer: 4

    ls prints the two names as text, and the unquoted substitution splits that text at the spaces into Q1, sales.csv, Q2 and sales.csv: four words, none of them a file. for f in * makes two passes, one per 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:

The loop variable after a list, a C-style loop, an empty glob and a function Python · after_session.py
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

  1. Question 1 of 2 After for ((i = 0; i < 5; i++)); do :; done, what is the value of i?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 5

    The body saw 0 to 4. After the pass with 4, i++ made i 5, the test i < 5 failed and the loop ended, so i is one step past the last pass, just as the session's loop up to 3 left i at 4.

  2. Question 2 of 2 A script sets f=summary.txt and then calls a function that runs for f in *.log; do gzip -- "$f"; done in a folder holding a.log and b.log. What is f after the call, and what keeps it summary.txt?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: b.log; a line local f at the top of the function

    In the session, tidy replaced the caller's budget.xlsx with y.log, and tidy_local, which starts with local f, left it alone.

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.

  1. Question 1 of 20 When does Bash expand the words of for name in words?

    Choose one answer.

    Show the answer: Question 1 of 20

    Answer: Once, before the first pass; the body then runs once for each word that results

    That is why quoting decides the number of passes: "$city" $city end was expanded into four words before the first pass ran.

  2. Question 2 of 20 dirs=("old logs" tmp "my docs"). How many passes does for d in ${dirs[@]} make, written without quotes?

    Type a number.

    Show the answer: Question 2 of 20

    Answer: 5

    Unquoted, each element is split at its spaces into old, logs, tmp, my and docs. Quoted as "${dirs[@]}", the loop makes three passes, one per element, as "${files[@]}" kept June budget.xlsx whole in the session.

  3. Question 3 of 20 What does echo $? print after unset list; for x in $list; do false; done?

    Type a number.

    Show the answer: Question 3 of 20

    Answer: 0

    The list expands to no words, so the body, false included, never runs, and the loop itself reports status 0. With the words a and b, the session's loop ended with 1, the status of the last false.

  4. Question 4 of 20 A script must handle every argument it receives, including empty ones and ones with spaces. Which loops do that?

    Choose every answer that is right.

    Show the answer: Question 4 of 20

    Answer:

    • for arg; do
    • for arg in "$@"; do

    The short form and the quoted "$@" are the same loop; any unquoted form, or "$*", changes the arguments.

  5. Question 5 of 20 In what order does for f in *.txt hand the names to the loop?

    Choose one answer.

    Show the answer: Question 5 of 20

    Answer: Sorted, because Bash sorts the names a glob matches

    Each matching name becomes one word of the list, in sorted order, and the name is never split.

  6. Question 6 of 20 f holds the name to do.txt. Which arguments does wc receive from wc -c $f?

    Choose one answer.

    Show the answer: Question 6 of 20

    Answer: Two: to and do.txt

    The glob kept the name whole in f, but every use in the body has to be quoted too: wc -c -- "$f" counted the file.

  7. Question 7 of 20 After shopt -s nullglob, in a folder without a .csv file, how many passes does for f in *.csv make?

    Type a number.

    Show the answer: Question 7 of 20

    Answer: 0

    With nullglob, a pattern that matches nothing expands to nothing, so the list is empty and the body never runs; the session's loop printed only its status, 0.

  8. Question 8 of 20 After rename-photos.sh --apply, the session ran the script again in the same folder. Why did it print only skip lines?

    Choose one answer.

    Show the answer: Question 8 of 20

    Answer: The renamed photos no longer match IMG_*.jpg, and the two IMG files left have no date or would replace an existing file

    Deciding from what is in the folder on every run is what makes a second run harmless.

  9. Question 9 of 20 What does echo {01..05} print in Bash?

    Choose one answer.

    Show the answer: Question 9 of 20

    Answer: 01 02 03 04 05

    Zero padding is one reason to choose a brace range when the bounds are fixed.

  10. Question 10 of 20 In for ((a; b; c)), when does each of the three expressions run?

    Choose one answer.

    Show the answer: Question 10 of 20

    Answer: a once before the first pass, b before every pass, c after every pass

    for ((i = 1; i <= n; i++)) sets i to 1, tests i <= n before each pass and adds 1 after each.

  11. Question 11 of 20 n=4. Which of these loops print 1 2 3 4 in Bash?

    Choose every answer that is right.

    Show the answer: Question 11 of 20

    Answer:

    • for ((i = 1; i <= n; i++))
    • for i in $(seq 1 "$n")

    The session printed {1..4} for {1..$n}, and 1 2 3 4 for the C-style loop and for seq.

  12. Question 12 of 20 Which of these loops runs the same way in Bash and in dash?

    Choose one answer.

    Show the answer: Question 12 of 20

    Answer: for i in 1 2 3

    Brace ranges and the C-style form belong to Bash, so a script that sh must run cannot rely on either.

  13. Question 13 of 20 times-table.sh prints each product with printf '%4d'. How many characters long is each row of bash times-table.sh 6, not counting the newline?

    Type a number.

    Show the answer: Question 13 of 20

    Answer: 24 characters

    Each row holds 6 products and each takes exactly 4 characters, padded with spaces on the left: 6 × 4 = 24. The rows of the table for 12 were 48 characters long.

  14. Question 14 of 20 What does ShellCheck report for for f in $(ls)?

    Choose one answer.

    Show the answer: Question 14 of 20

    Answer: SC2045, an error: iterating over ls output is fragile, use globs

    The fix ShellCheck asks for is the one the session ran: for f in * made one pass per file.

  15. Question 15 of 20 A folder holds a.txt, b.txt and list.txt, and list.txt has the single line * water the plants. How many passes does for w in $(cat list.txt) make?

    Type a number.

    Show the answer: Question 15 of 20

    Answer: 6

    The line is split into , water, the and plants, and the is then expanded into the three file names: 3 + 3 = 6 passes. The session's two to-do lines made eleven in the same way.

  16. Question 16 of 20 In a folder of three files, how many passes does for f in "$(ls)" make?

    Choose one answer.

    Show the answer: Question 16 of 20

    Answer: One, with the three names and the newlines between them in one word

    Neither form gives one name per pass. A glob does, because each match is a word of its own.

  17. Question 17 of 20 packages.txt lists one package per line, and some lines hold a name and a version separated by a space. Which loop gets every line whole?

    Choose one answer.

    Show the answer: Question 17 of 20

    Answer: while IFS= read -r line; do …; done < packages.txt

    ShellCheck's SC2013 points to while read for exactly this case, and the session's read loop kept both to-do lines whole.

  18. Question 18 of 20 After for f in a.txt b.txt c.txt; do :; done, what does f hold?

    Choose one answer.

    Show the answer: Question 18 of 20

    Answer: c.txt

    The session printed f=b.txt after its loop over two names.

  19. Question 19 of 20 With nullglob on and no .log file in the folder, what does last=none; for last in *.log; do :; done; echo "$last" print?

    Choose one answer.

    Show the answer: Question 19 of 20

    Answer: none

    Give the variable a value before a loop that may not run, as the session did with last=none.

  20. Question 20 of 20 A script counts with for ((i = 0; i < count; i++)) and afterwards prints "done $i". With count=3, what does it print?

    Choose one answer.

    Show the answer: Question 20 of 20

    Answer: done 3

    Starting at 0 with the test i < count, the C-style loop leaves i equal to count; the session's loop from 1 to 3 left 4.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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 words expands the words once and runs the body for each word left, so quoting decides the passes: "$var" and "${array[@]}" keep items whole, and for arg; means for 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 ]] || continue or shopt -s nullglob.
  • Brace ranges need typed bounds, the C-style for (( … )) takes variables and any step, and seq is 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 with while 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 … in loop that makes no pass never sets it, and inside a function it should be local.

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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.