Signal9Signal9 — home
online
all lessons

Loops: Automating Repetition

Beginner35 min6 steps
What you'll learn
  • Explain what a loop buys you — one piece of logic applied to many things — and pick for vs while by shape
  • Write for loops over lists, ranges, command output, and files
  • Use while loops, including reading a file line by line the correct way
  • Control loops with break and continue
  • Apply loops to real batch tasks against real files
  1. Why loops exist: write once, apply to many

    Build the mental model — a loop is an assembly line, and the two loop types differ by what ends them

    Picture the task that makes people learn scripting in the first place: "compress every log older than a week — on all forty of them." Without a loop you'd type the same command forty times, changing one filename each time. Typos creep in around file twelve; boredom-induced mistakes around twenty. The loop is the fix, and it's the heart of all automation: you write the logic once, and the shell applies it to each item in a list.

    Think of it as an assembly line. Items arrive one at a time — filenames, numbers, server names, lines of a file. Your loop body is the workstation: each item stops, gets processed with the same steps, and moves on. The variable ($file, $i, $host) is just the name of whatever is currently on the belt.

    Bash gives you two loop shapes, and choosing between them is a one-question decision — do you know the list up front?

    • `for` — "do this for each of THESE." You have the items already: five filenames, the numbers 1–10, three hostnames. The loop ends when the list runs out.
    • `while` — "keep doing this UNTIL something changes." You don't know how many rounds: retry until a service answers, read lines until the file ends, count until a threshold. The loop ends when its condition — the same exit-code test you learned with if — goes false.

    That's the whole taxonomy. Everything in this lesson is one of those two shapes wearing different clothes.

    Check yourself

    You're writing a script that pings a host every 5 seconds until it responds. for or while — and why?

  2. The for loop

    Iterate a literal list, a range, and command output with one syntax

    The syntax reads almost like English: for fruit in apple banana cherry; do ... done. Bash sets $fruit to the first item, runs the body, sets it to the second, runs it again — until the list is exhausted. do and done fence the body the way then and fi fence an if.

    The power move is realizing how many things can be the list: literal words, a numeric range like {1..5} (brace expansion), the output of a command via $(...), or — next step — filename globs.

    for fruit in apple banana cherry; do
      echo "I like $fruit"
    done
    I like apple
    I like banana
    I like cherry
    Three items, three trips through the body. On each trip $fruit holds the current item — nothing more mysterious than that. This literal-list form is what you'll use for small fixed sets: three services to restart, two directories to back up.
    for i in {1..3}; do echo "attempt $i"; done
    for user in $(cut -d: -f1 /etc/passwd | head -3); do echo "account: $user"; done
    attempt 1
    attempt 2
    attempt 3
    account: root
    account: bin
    account: daemon
    d:
    (cut) split fields on the colon delimiter — /etc/passwd's separator
    f1
    (cut) keep field 1, the username
    Two more list sources. {1..3} expands to 1 2 3 before the loop even starts ({1..10..2} would step by 2). The second loop's list is command output: $(...) runs the pipeline, and its words become the items. Real usernames from the real passwd file — the loop doesn't care where the list came from.

    Check yourself

    What brace-expansion writes the numbers 1 through 5 for a for loop?

  3. Loop over files — the pattern you'll use most

    Process every file matching a glob, safely, including the no-match trap

    Here's the workhorse. Give for a filename glob and the shell expands it into the list of matching paths — every .log in a directory, every .conf under /etc. This is the forty-log-files problem from the intro solved in one line.

    Two safety habits make the difference between a pattern you can trust and one that burns you, and both are visible in the next command. First: quote `"$file"` everywhere you use it — filenames with spaces are legal, and an unquoted variable splits into pieces. Second: guard against the no-match case, which is stranger than you'd expect.

    for file in /var/log/*.log; do
      [ -e "$file" ] || continue
      echo "$file is $(du -h "$file" | cut -f1)"
    done
    /var/log/boot.log is 8.0K
    /var/log/dnf.log is 124K
    /var/log/lastlog is 12K
    h
    (du) human-readable size — 124K instead of a raw block count
    e
    (test) true if the path exists — any type
    The glob expands to every matching path and the body reports each one's human-readable size. The guard line is doing quiet, important work: if a path doesn't actually exist, skip to the next item. Why would it not exist? That's the trap below.

    Check yourself

    Your loop is for f in /data/*.csv; do rm "$f"; done and /data has no CSV files. What does the loop actually try to delete?

  4. while loops — repeat until the world changes

    Loop on a condition, and read a file line by line the correct way

    while reuses everything you learned about conditionals: while CONDITION; do ... done re-runs the body as long as the condition's exit code is 0. Anything that can follow if can follow while — a [ ] test, a grep -q, a ping -c1. The body's job is usually to change something the condition checks, or you've written an infinite loop.

    The most important while in all of shell scripting, though, is the one that reads a file line by line — and it's worth learning its exact incantation, because the "obvious" alternative is a famous bug.

    count=1
    while [ "$count" -le 3 ]; do
      echo "count $count"
      ((count++))
    done
    count 1
    count 2
    count 3
    The anatomy: the condition tests $count, the body increments it with ((count++)) — Bash's arithmetic syntax — and after three trips the -le test fails and the loop ends. Delete the increment line and you've built the classic infinite loop: the condition checks a value nothing ever changes (Ctrl+C rescues you).
    while IFS= read -r line; do
      echo "got: $line"
    done < /etc/hostname
    got: web01.example.com
    r
    (read) raw mode — backslashes in the line stay literal instead of escaping things
    IFS=
    empty field separator for this read — keeps the line's whitespace untouched
    THE way to read a file line by line. read pulls one line per iteration and exits non-zero at end-of-file — which is what ends the loop. The < /etc/hostname at the bottom feeds the whole loop from the file. IFS= preserves leading/trailing whitespace exactly, and -r stops backslashes being interpreted. For CSV, set the separator and name multiple fields: while IFS=',' read -r name email; do ...

    Check yourself

    What's the safe construct to read a file one line at a time?

  5. Control the loop: break and continue

    Exit early or skip an item without restructuring the loop

    Real batch jobs aren't uniform: some items should be skipped (a file that fails validation), and sometimes the whole job should stop early (you found what you were searching for). Two keywords handle both without touching the loop's structure:

    • `continue` — "done with THIS item, next please." Skips the rest of the body for the current item.
    • `break` — "done with the WHOLE loop." Exits immediately; execution resumes after done.

    You've already used continue once — the glob guard [ -e "$f" ] || continue is exactly this: item's no good, next.

    for i in {1..6}; do
      if [ $((i % 2)) -eq 0 ]; then continue; fi
      if [ "$i" -eq 5 ]; then break; fi
      echo "odd: $i"
    done
    echo "loop finished"
    odd: 1
    odd: 3
    loop finished
    Trace it item by item: 1 is odd → printed. 2 is even → continue skips it. 3 → printed. 4 → skipped. 5 → break kills the loop before printing, and 6 is never visited at all. The echo after done proves execution lands there. This trace-by-hand exercise is worth doing once slowly — break/continue bugs are almost always a mis-predicted trace.

    Check yourself

    A loop searches 500 log files for the first one containing "FATAL". Once found, checking the rest is wasted work. Which keyword stops the loop — break or continue?

  6. Now do it — batch-process real files in your live terminal

    Run each loop shape against real data and trigger the no-match trap on purpose

    Loops only become instinct through your own fingers. You'll run all three shapes — a range for, a glob for over real system files, and a line-reading while — then spring the unmatched-glob trap deliberately so you recognize it forever.

    for i in {1..5}; do echo "square of $i is $((i*i))"; done
    The range loop with arithmetic — $((...)) does the math. Change the range to {2..10..2}, or the operation to iii, and predict the output before you press Enter. Predicting-then-checking is the fastest way to calibrate your mental model.
    for f in /etc/*.conf; do [ -e "$f" ] || continue; echo "$(basename "$f"): $(wc -l < "$f") lines"; done | head
    The glob loop doing genuinely useful work: every .conf in /etc, each reported with its line count. Note the guard is present even though /etc certainly has .conf files — it costs nothing and the habit pays off the day the glob is emptier than you assumed.
    mkdir -p /tmp/loop-lab && cd /tmp/loop-lab
    for f in *.nothing; do echo "item: $f"; done
    The trap, on your terms: an empty directory, a glob that matches nothing — and the loop still prints 'item: *.nothing'. That literal string is what your rm or gzip would have received. Now add the guard ([ -e "$f" ] || continue) and run it again: silence. That before/after is the lesson.
    printf "web01\ndb01 primary\ncache02\n" > hosts.txt
    while IFS= read -r host; do echo "checking: [$host]"; done < hosts.txt
    Build a three-line inventory (one entry contains a space) and read it back line by line. The brackets in the output prove each line arrived whole — 'db01 primary' stays one item. Now try the broken form, for h in $(cat hosts.txt), and watch it split into four fragments. Seeing both makes the rule stick.
Recap
  • A loop applies one body to many items — write the logic once, review it once, trust it everywhere. Choose by shape: for when you have the list, while when a condition decides.
  • for VAR in LIST; do ... done — the list can be literal words, a {1..5} range, $(command) output, or a glob; always quote "$VAR" in the body.
  • An unmatched glob stays literal (.log arrives as the string .log) — guard file loops with [ -e "$f" ] || continue, or shopt -s nullglob.
  • Read files with while IFS= read -r line; do ...; done < file — never for line in $(cat file), which splits on every space, not on lines.
  • continue skips the current item, break exits the whole loop; iterate arrays with "${arr[@]}" to keep elements with spaces intact.
Loop → form
NeedForm
Fixed list / rangefor x in a b c · for i in {1..5}
Index mathfor ((i=0;i<n;i++))
Each matching filefor f in *.log; do [ -e "$f" ] || continue
Repeat until conditionwhile [ cond ]; do ... done
Read a file by lineswhile IFS= read -r l; do ...; done < f
Skip item / stop loopcontinue · break
Fixed rosterfor s in "${servers[@]}"