Back to Blog

Zsh Shell Tutorial 10: Loops — for, while, until & File Processing

Sandy LaneSandy Lane

Video: Zsh Shell Tutorial 10: Loops — for, while, until & File Processing by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Zsh Lesson 10: Loops — for, while, until, File Processing

for x in list; do ... done. for ((i=0; i<10; i++)) C-style. while [[ cond ]]; do ... done. until is "while NOT." break exits, continue skips to next iteration. The "read a file line by line" pattern: while IFS= read -r line; do ... done < file.

Loops are how scripts process collections — files, names, log entries, anything iterable.

for: list iteration

for fruit in apple banana cherry; do
  echo "Fruit: $fruit"
done

Output:

Fruit: apple
Fruit: banana
Fruit: cherry

for VAR in LIST; do ... done. Each iteration assigns VAR to the next item.

Brace expansion

for i in {1..5}; do
  echo "Count: $i"
done
# Count: 1 ... Count: 5

for i in {0..20..5}; do    # step 5
  echo $i
done
# 0 5 10 15 20

{a..b} and {a..b..step} generate ranges before the loop runs.

C-style for

for ((i=0; i<3; i++)); do
  echo "Index: $i"
done

Familiar from C/Java/JS. init; condition; update. Useful when you need an index, not values.

Iterating over files

for f in *.txt; do
  echo "Processing: $f"
  wc -l "$f"
done

Globs expand to filenames before the loop runs. Each iteration $f is one file.

for f in **/*.log; do
  echo "$f"
done

**/ recursively matches subdirectories (Zsh extension; need setopt EXTENDED_GLOB in some versions).

Iterating over command output

for line in $(grep ERROR log.txt); do
  echo "$line"
done

But this splits on whitespace, not lines. For real line-by-line processing, use while read (below).

while: until condition becomes false

count=1
while [[ $count -le 5 ]]; do
  echo "Count: $count"
  ((count++))
done

Standard counting loop. Same as a for would do, just more explicit.

until: opposite of while

num=5
until [[ $num -eq 0 ]]; do
  echo "Countdown: $num"
  ((num--))
done
echo "Liftoff!"

until cond runs the loop while the condition is FALSE. Stops when it becomes true. Less common than while, but occasionally clearer.

Reading a file line by line

while IFS= read -r line; do
  echo "Line: $line"
done < input.txt

This is the canonical "process a file" loop:

  • IFS= — preserve leading/trailing whitespace.
  • -r — don't interpret backslashes.
  • < input.txt — redirect file as stdin.

Process each line:

while IFS= read -r line; do
  if [[ "$line" == *ERROR* ]]; then
    echo "FAIL: $line"
  fi
done < server.log

Reading from a command

ls *.txt | while IFS= read -r f; do
  echo "Found: $f"
done

But: variables set inside this while don't persist after, because pipe creates a subshell. Workaround:

# Process substitution (Zsh/Bash extension)
while IFS= read -r f; do
  count=$((count + 1))
done < <(ls *.txt)
echo "Total: $count"

Or just use a for with a glob.

break: exit early

for name in Alice Bob Charlie Dave; do
  if [[ "$name" == "Charlie" ]]; then
    echo "Found!"
    break
  fi
  echo "Checking: $name"
done

break exits the innermost loop. break 2 exits two levels (nested loops).

continue: skip to next iteration

for i in {1..10}; do
  if (( i % 2 == 0 )); then
    continue        # skip evens
  fi
  echo "Odd: $i"
done

continue jumps to the next iteration without running the rest of the body.

Infinite loops

while true; do
  echo "tick"
  sleep 1
done

while true runs forever. Use break (or Ctrl-C) to exit.

For services or watchers, this is the foundation.

A file processor

#!/usr/bin/env zsh
set -euo pipefail

if [[ $# -eq 0 ]]; then
  echo "Usage: $0 <file>..." >&2
  exit 1
fi

total_lines=0
for file in "$@"; do
  if [[ ! -f "$file" ]]; then
    echo "Skipping (not a file): $file" >&2
    continue
  fi
  lines=$(wc -l < "$file")
  total_lines=$((total_lines + lines))
  echo "$file: $lines lines"
done
echo "Total: $total_lines lines"

Iterate over args; skip non-files; accumulate. Standard pattern.

Loop control with multiple conditions

attempt=0
max_attempts=5

while (( attempt < max_attempts )); do
  if curl -sf "$URL" > result.json; then
    break
  fi
  ((attempt++))
  sleep 5
done

if (( attempt == max_attempts )); then
  echo "Failed after $max_attempts attempts" >&2
  exit 1
fi

Retry pattern: try, on success break, on failure increment and sleep.

Iterating over array

fruits=(apple banana cherry)

for fruit in $fruits; do      # Zsh: auto-expands
  echo "$fruit"
done

for fruit in "${fruits[@]}"; do   # portable form
  echo "$fruit"
done

for ((i=1; i<=${#fruits}; i++)); do   # by index (Zsh: 1-based)
  echo "$i: $fruits[$i]"
done

Lesson 12 covers arrays in depth.

select: interactive menu

select choice in apple banana cherry quit; do
  case "$choice" in
    apple) echo "An apple a day..." ;;
    banana) echo "Going bananas." ;;
    cherry) echo "Cherry on top." ;;
    quit) break ;;
    *) echo "Invalid" ;;
  esac
done

select builds a numbered menu and loops until you break. Quick way to add interactive menus to scripts.

Common stumbles

for line in $(cat file) splits on whitespace. Use while IFS= read -r line; do ... done < file.

Modifying state in piped while. cat file | while ...; var=...; donevar is in a subshell. Use process substitution < <(cmd) or store the count via tempfile.

((count++)) and set -e. (( count++ )) returns non-zero when count is 0 (because the expression evaluates to 0). With set -e, that exits the script. Use count=$((count + 1)) or (( count++, 1 )).

Forgot done. "syntax error: unexpected end of file." Every loop needs done.

Empty glob. for f in *.txt with no .txt files iterates once with f="*.txt" (literal). Set setopt nullglob for empty-list behavior, or check inside:

for f in *.txt; do
  [[ -e "$f" ]] || continue
  ...
done

break in piped loops. cat | while; do break; done may not actually exit the outer pipeline cleanly. Use process substitution.

Slow loops in shell. Shell loops are not fast. For 10,000+ iterations, look at awk, xargs -P, or write the inner logic in a faster language.

What's next

Lesson 11: functions. Arguments, return values, scope.

Recap

for x in list; do ... done — list iteration. for ((i=0; i<n; i++)) — C-style index. while [[ cond ]]; do ... done, until for the inverse. while IFS= read -r line; do ... done < file for line-by-line file reading. break exits, continue skips. select for interactive menus. Watch for subshell pitfalls in piped loops; prefer process substitution < <(cmd).

Next lesson: functions.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.