Shell by Example: For Loops POSIX + Bash

The for loop iterates over a list of items, running the same code for each one. The basic syntax is:

for VARIABLE in LIST; do commands using $VARIABLE done

The loop assigns each item from LIST to VARIABLE, then executes the commands between do and done. The keywords do and done mark the start and end of the loop body.

Edit
#!/bin/sh
echo "Iterating over fruits:"
for fruit in apple banana cherry; do
    echo "  I like $fruit"
done
Output:
Iterating over fruits:
  I like apple
  I like banana
  I like cherry

Loops eliminate repetitive code and let you process collections of items. Instead of writing the same command multiple times with different values, you write it once and let the loop handle each item.

The loop approach is easier to modify, extend, and understand when dealing with many items.

Edit
#!/bin/sh
echo "Without a loop (repetitive):"
echo "  processing: file1"
echo "  processing: file2"
echo "  processing: file3"

echo ""
echo "With a loop (scalable):"
for file in file1 file2 file3; do
    echo "  processing: $file"
done
Output:
Without a loop (repetitive):
  processing: file1
  processing: file2
  processing: file3

With a loop (scalable):
  processing: file1
  processing: file2
  processing: file3

The seq command generates sequences of numbers for iteration. While not POSIX-standard, seq is widely available on Linux and macOS systems.

Syntax forms:

  • seq END: Count from 1 to END
  • seq START END: Count from START to END
  • seq START STEP END: Count from START to END by STEP

The output of seq is used with command substitution to generate the list of items for the loop.

Edit
#!/bin/sh
echo "Count 1 to 5:"
for n in $(seq 5); do
    echo "  $n"
done

echo ""
echo "Count 3 to 7:"
for n in $(seq 3 7); do
    echo "  $n"
done

echo ""
echo "Count by 2s (1 to 9):"
for n in $(seq 1 2 9); do
    echo "  $n"
done
Output:
Count 1 to 5:
  1
  2
  3
  4
  5

Count 3 to 7:
  3
  4
  5
  6
  7

Count by 2s (1 to 9):
  1
  3
  5
  7
  9

Bash

Bash provides two convenient syntaxes for numeric iteration that don’t require external commands like seq.

Brace expansion {START..END} generates a sequence at parse time. You can optionally add a step: {START..END..STEP}. Note: brace expansion happens before variable expansion, so you cannot use variables in the range.

C-style for loops ((init; condition; increment)) work like for loops in C, Java, or JavaScript. The three parts are: initialization, continue condition, and increment expression. This syntax supports variables.

Edit
#!/bin/bash
echo "Brace expansion {1..5}:"
for n in {1..5}; do
    echo "  $n"
done

echo ""
echo "Brace expansion with step {1..10..2}:"
for n in {1..10..2}; do
    echo "  $n"
done

echo ""
echo "C-style for loop:"
for ((i = 1; i <= 5; i++)); do
    echo "  $i"
done

echo ""
echo "C-style counting down:"
for ((i = 3; i >= 1; i--)); do
    echo "  $i"
done
Output:
Brace expansion {1..5}:
  1
  2
  3
  4
  5

Brace expansion with step {1..10..2}:
  1
  3
  5
  7
  9

C-style for loop:
  1
  2
  3
  4
  5

C-style counting down:
  3
  2
  1

Glob patterns (like *.txt) expand to matching filenames, making it easy to loop over files in a directory.

Common glob patterns:

  • *.txt: All .txt files in current directory
  • */tmp/*: All files in /tmp
  • data?.csv: data1.csv, data2.csv, etc.

Important: if no files match the pattern, the glob expands to the literal pattern string. The defensive check [ -e "$file" ] || continue skips non-existent entries, preventing errors when the glob matches nothing.

Edit
#!/bin/sh
touch /tmp/file1.txt /tmp/file2.txt /tmp/file3.txt

echo "Simple file iteration:"
for file in /tmp/file1.txt /tmp/file2.txt /tmp/file3.txt; do
    echo "  found: $file"
done

echo ""
echo "Using a glob pattern:"
for file in /tmp/file*.txt; do
    echo "  found: $file"
done

echo ""
echo "Defensive pattern (handles no matches):"
for file in /tmp/nonexistent*.xyz; do
    [ -e "$file" ] || continue
    echo "  found: $file"
done
echo "  (no files matched, loop body never ran)"

rm /tmp/file1.txt /tmp/file2.txt /tmp/file3.txt
Output:
Simple file iteration:
  found: /tmp/file1.txt
  found: /tmp/file2.txt
  found: /tmp/file3.txt

Using a glob pattern:
  found: /tmp/file1.txt
  found: /tmp/file2.txt
  found: /tmp/file3.txt

Defensive pattern (handles no matches):
  (no files matched, loop body never ran)

Command substitution $(command) lets you loop over the output of any command. The output is split into words, and the loop iterates over each word.

This pattern is useful for processing dynamic data like usernames, package lists, or filtered results.

Caution: word splitting occurs on spaces, tabs, and newlines. If your data contains spaces, consider using while read instead (covered in while-loops).

Edit
#!/bin/sh
echo "First 5 users from /etc/passwd:"
for user in $(cut -d: -f1 /etc/passwd | head -5); do
    echo "  - $user"
done

echo ""
echo "Simple word list from echo:"
for word in $(echo "one two three"); do
    echo "  word: $word"
done
Output:
First 5 users from /etc/passwd:
  - root
  - bin
  - daemon
  - lp
  - sync

Simple word list from echo:
  word: one
  word: two
  word: three

Use "$@" to loop over script arguments. Each argument is preserved as a separate item, even if it contains spaces. This pattern is essential for scripts that process multiple files or values passed by the user.

The set -- command replaces positional parameters, useful for testing argument handling within a script. In a real script, arguments come from the command line.

Shorthand: for arg; do is equivalent to for arg in "$@"; do. When in LIST is omitted, the loop defaults to "$@".

Edit
#!/bin/sh
set -- "first arg" "second arg" "third arg"

echo "Looping over \"\$@\":"
for arg in "$@"; do
    echo "  arg: $arg"
done

echo ""
echo "Shorthand (omit 'in \"\$@\"'):"
for arg; do
    echo "  arg: $arg"
done
Output:
Looping over "$@":
  arg: first arg
  arg: second arg
  arg: third arg

Shorthand (omit 'in "$@"'):
  arg: first arg
  arg: second arg
  arg: third arg

Bash

Bash arrays store multiple values in a single variable. Use "${array[@]}" to expand all elements for iteration, preserving elements that contain spaces.

This is similar to "$@" for positional parameters. The quotes and [@] together ensure each element is treated as a separate item.

Note: Arrays are a Bash feature and are not available in POSIX sh.

Edit
#!/bin/bash
fruits=("apple" "banana" "cherry pie")

echo "Iterating over array elements:"
for fruit in "${fruits[@]}"; do
    echo "  - $fruit"
done

echo ""
echo "Array indices are also available:"
for i in "${!fruits[@]}"; do
    echo "  index $i: ${fruits[$i]}"
done
Output:
Iterating over array elements:
  - apple
  - banana
  - cherry pie

Array indices are also available:
  index 0: apple
  index 1: banana
  index 2: cherry pie

The break and continue statements control loop flow:

  • break exits the loop immediately, skipping remaining iterations and continuing after done
  • continue skips the rest of the current iteration and moves to the next item

These are useful for stopping early when you find what you need (break) or skipping items that don’t apply (continue).

Edit
#!/bin/sh
echo "Using 'break' to exit early:"
for n in 1 2 3 4 5; do
    if [ "$n" -eq 3 ]; then
        echo "  found 3, stopping"
        break
    fi
    echo "  checking $n"
done

echo ""
echo "Using 'continue' to skip items:"
for n in 1 2 3 4 5; do
    if [ "$n" -eq 3 ]; then
        echo "  skipping 3"
        continue
    fi
    echo "  processing $n"
done
Output:
Using 'break' to exit early:
  checking 1
  checking 2
  found 3, stopping

Using 'continue' to skip items:
  processing 1
  processing 2
  skipping 3
  processing 4
  processing 5

The loop variable persists after the loop ends, retaining the value from the last iteration. This differs from many programming languages where loop variables are scoped to the loop body.

This behavior can be useful when you need to know the last processed item, but be careful to avoid accidentally reusing a variable name from a previous loop.

Edit
#!/bin/sh
for letter in a b c; do
    : # do nothing (: is a no-op command)
done
echo "After loop, letter = $letter"

echo ""
echo "Practical use - find last matching item:"
last_txt=""
for file in /etc/passwd /etc/hosts /etc/fstab; do
    if [ -f "$file" ]; then
        last_txt="$file"
    fi
done
echo "Last existing file: $last_txt"
Output:
After loop, letter = c

Practical use - find last matching item:
Last existing file: /etc/fstab

« Case Statements | While Loops »