We closed Module 3 with a promise: daily-report.sh would stop being a linear script and become a program with structure, and loops would be the first tool to arrive. Here they are. Until now, when you wanted the Valencia report and the Sevilla one, you ran the script twice; when you wanted to look at the four rotated app.log files, you copied and pasted the same pipeline four times, changing the name. A loop is exactly the opposite: you write the operation once and hand it the list of things to apply it to. In this lesson you will see the four ways of iterating that Bash offers, when to use each one, and the canonical pattern —with its traps— for reading a file line by line.
Contents
forover literal listsforover globs and over"$@"- C-style
for whileanduntil- Which one to choose: comparison table
- Reading a file line by line: the canonical pattern
- The pipeline and subshell trap
- Reading a CSV splitting fields
breakandcontinue, and their levels- Controlled infinite loops
- Nesting, counters and cost
for over literal lists
for over literal listsThe simplest form of for walks a list of space-separated words, assigning each one to a variable:
It prints four lines, one per city. Breakdown of each piece:
for city in ...:cityis the control variable. It is not declared beforehand nor deleted afterwards: when the loop ends it keeps the last value (Madrid).- The list after
inis a list of words, not a string. Bash builds it by applying the expansions from 03-06:for n in {1..5}orfor f in $(date +%F)are equally valid. do...donedelimit the body. The;beforedois only needed if they are on the same line.
Careful: quoting a string with spaces (for c in "$CITIES") produces a single element, not several. For real lists you use arrays (04-03).
for over globs and over "$@"
for over globs and over "$@"Since globbing (02-05) happens before the command runs, a pattern turns into the list of files that exist. This is the correct idiom for walking files, and the reason why in 03-06 we insisted that you never write for f in $(ls):
for file in /var/log/veloz/app.log.*; do
[[ -f "$file" ]] || continue # guards against the "no matches" case
echo "== $file: $(wc -l < "$file") lines"
doneThe guard line matters: if no app.log.* exists, Bash leaves the pattern unexpanded and the variable will literally hold /var/log/veloz/app.log.*. The two fixes are the guard [[ -f ... ]] || continue or enabling shopt -s nullglob, which makes a pattern with no matches produce an empty list so the loop never runs.
With no in list, for implicitly walks the script's arguments, that is "$@":
Writing for city in "$@" is more explicit and always correct. Writing for city in $@ (unquoted) breaks with any argument containing spaces, for the reason you already know from 03-05.
- C-style
for
forWhen what you need is a numeric counter and not a list, Bash offers the three-arithmetic-expression syntax inherited from C:
Inside the double parentheses you are in arithmetic context: variables carry no $, they compare numerically with < and >, and i++ works. It is the same context you will see in depth in 04-06. The three expressions are initialization, continuation condition and increment, and any of them can be omitted (for (( ;; )) is an infinite loop).
Use it when the index matters; to walk values, for ... in reads better.
while and until
while and untilwhile repeats the body while a command returns exit code 0, and until repeats until it does. Key note that links back to 03-04: the condition is not a boolean expression, it is a command whose $? is evaluated.
attempts=0
while ! curl -sf http://localhost:8080/salud > /dev/null; do
attempts=$(( attempts + 1 ))
(( attempts >= 5 )) && { echo "veloz-api is not responding" >&2; exit 4; }
echo "Waiting for veloz-api (attempt $attempts)..."
sleep 2
done
echo "veloz-api is up"The same loop with until drops the negation and reads better:
Rule of thumb: if your while starts with !, you probably wanted an until.
- Which one to choose: comparison table
| Form | Use it when | Typical Veloz Envíos example |
|---|---|---|
for x in list |
You know the elements in advance | The four cities, the couriers |
for (( i=0; i<n; i++ )) |
You need a numeric index | Walking an array by position, numbered retries |
while cond |
You repeat while something holds and you don't know how many times | Reading log lines until the end |
until cond |
Same as while, but the natural condition is the negative one |
Waiting for the API to start |
for answers "for each one of these"; while/until answer "while/until this happens".
- Reading a file line by line: the canonical pattern
This is probably the Bash snippet you will write most often in your life. Memorize it whole:
Every piece is there for a specific reason:
IFS=empties the field separator for this command only (remember from 03-06 thatVAR=value commandaffects only that command). Without it,readwould strip leading and trailing spaces and tabs from each line. WithIFS=the line arrives literal, with its indentation intact.-rdisables backslash interpretation. Without-r, a path likeC:\veloz\datawould lose its backslashes and a trailing\would join two lines. Unless you are implementing an escape interpreter, always-r.lineis the target variable. If you supply none,readdrops the content intoREPLY.< fileat the end: the redirection applies to the whole loop, which acts as a single compound command.readconsumes that input line by line until it runs out, at which point it returns a non-zero code and thewhileends.
A little-known warning: if the file does not end in a newline, read stores the last line but returns a failure code and the loop discards it. The safe version is while IFS= read -r line || [[ -n "$line" ]]; do ... done < file.
- The pipeline and subshell trap
It is tempting to write the previous loop feeding it from a pipeline. It works… until you try to use a variable afterwards:
errors=0
grep '\[ERROR\]' /var/log/veloz/app.log | while IFS= read -r line; do
errors=$(( errors + 1 ))
done
echo "Errors: $errors" # prints 0, not the real totalThe explanation comes straight from 01-04: each stage of a pipeline runs in a subshell, a child process with its own copy of the variables. The loop really does increment errors, but in the child's copy; when the child dies, the value dies with it and the parent still sees its original 0.
flowchart LR
A["Parent shell<br/>errors=0"] -->|"| pipeline"| B["Subshell<br/>errors=0→1→2"]
B -.->|"the child dies<br/>and takes the value"| C["Parent shell<br/>errors=0"]
A ==>|"< redirection"| D["Same shell<br/>errors=2 ✓"]
The two correct fixes:
# A) Redirection at the end: the loop runs in the current shell
errors=0
while IFS= read -r line; do errors=$(( errors + 1 )); done \
< <(grep '\[ERROR\]' /var/log/veloz/app.log)
# B) Process substitution with a real file
while IFS= read -r line; do ...; done < /var/log/veloz/app.logThe < <(command) construct is called process substitution: it turns a command's output into something you can redirect as if it were a file, without creating any subshell for the loop. Note the mandatory space between the two <.
- Reading a CSV splitting fields
read accepts several variables and distributes the fields according to IFS. Setting IFS=, gives you a one-line CSV reader:
{
read -r _header # discard the first line
while IFS=, read -r id date city courier status amount; do
[[ "$status" == "issue" ]] || continue
printf '%-8s %-10s %-10s %s\n' "$id" "$city" "$courier" "$amount"
done
} < /srv/veloz/data/shipments.csvOutput: E1043 Sevilla mgarcia 31.20, one line per issue. Three important details:
- The initial
read -r _headerconsumes theshipment_id,date,...line. Since it is inside the same{ ... } < fileblock, it shares the input with the loop. - If there are more fields than variables, the last variable receives all the rest. That is why
amount, being the last one, keeps whatever is left over. And if there are fewer, the trailing variables end up empty. - This reader does not understand quotes or commas inside fields. For complex CSV you use
awk(06-01); for ours, it is perfect.
break and continue, and their levels
break and continue, and their levelsbreak exits the loop; continue jumps to the next iteration. Both accept a number saying how many nesting levels they affect:
for city in Valencia Sevilla Bilbao Madrid; do
for courier in alopez mgarcia jruiz; do
if [[ ! -r "$CSV_PATH" ]]; then
echo "CSV unreadable, aborting everything" >&2
break 2 # exits BOTH loops
fi
[[ "$courier" == "jruiz" && "$city" == "Madrid" ]] && continue
echo "$city / $courier"
done
donebreak with no number is the same as break 1 and would only leave the inner loop. Levels are counted from the inside out. Use them sparingly: a break 3 is a sign that the block is asking to become a function with a return (04-02).
- Controlled infinite loops
A loop with no exit condition is a legitimate tool when the script is a watchdog:
while true; do
now=$(date '+%F %T')
errors=$(grep -c '\[ERROR\]' /var/log/veloz/app.log)
printf '%s accumulated errors: %s\n' "$now" "$errors"
sleep 60
donetrue is a command that does nothing and always returns 0, so the condition never fails. Safety rules:
- Always a
sleepinside. Without it you will burn a CPU at 100% for nothing. - Always a way out: a
breakunder some condition, or at least letCtrl+Ckill it (by default it does). - If the loop is going to live forever, the right answer is not a hand-launched
while truebut a system timer. You will see that in 07-04 and 07-05.
- Nesting, counters and cost
Nesting loops multiplies the work: 4 cities × 3 couriers is 12 iterations, and if inside you run grep over a 50,000-line CSV, that is 12 full reads. The real cost in Bash is almost never the loop, but the external processes launched inside it: a for over 4 cities with two grep each means 8 processes and 8 passes over the file.
A single pass accumulating counters is much faster, but for that you need one counter per city: that means arrays (04-03), and the percentage with decimals means arithmetic (04-06). For now, the simple counter, which you can already write:
total=0; issues=0
while IFS=, read -r _ _ city _ status _; do
(( total++ ))
[[ "$status" == "issue" ]] && (( issues++ ))
done < <(tail -n +2 "$CSV_PATH")
echo "$issues issues out of $total shipments"_ is a conventional variable name for "I don't care about this field"; it has no special meaning in Bash, it is just a readable habit.
Common Mistakes and Tips
for f in $(ls *.csv). Already flagged in 03-06, but it bears repeating: it breaks with spaces in names. Usefor f in *.csv.- Forgetting
IFS=or-rinread. It will work for months and fail the day a path with\or an indented line shows up. - Counting inside a pipeline. The counter will always come out as 0. Redirection or
< <(...), nevercmd | while. - Looping over a glob that does not match. Add the guard
[[ -e "$f" ]] || continueorshopt -s nullglob. - Modifying the list while walking it. The
forlist is computed once, at the start; deleting files inside the loop does not change what is left to walk. - Tip: when a loop goes past about 15 lines, extract the body into a function. That is exactly what you will do in the next lesson.
Exercises
Exercise 1. Write a loop that walks the four cities and, for each one, prints how many CSV lines belong to it, in the format Valencia: 128 shipments.
Exercise 2. Walk the rotated files /var/log/veloz/app.log.* and show, for each one, its name and the number of [ERROR] lines it contains, skipping those that do not exist or are empty.
Exercise 3. Read shipments.csv line by line (no header) and count how many shipments alopez made and how many of them ended in an issue. Print both numbers after the loop: they must come out correct.
Solutions
Solution 1.
CSV_PATH="/srv/veloz/data/shipments.csv"
for city in Valencia Sevilla Bilbao Madrid; do
n=$(grep -c ",${city}," "$CSV_PATH")
echo "${city}: ${n} shipments"
doneThe pattern ,${city}, with commas on both sides avoids false positives if one city were a substring of another.
Solution 2.
for f in /var/log/veloz/app.log.*; do
[[ -f "$f" && -s "$f" ]] || continue # -s: exists and is not empty
printf '%-32s %4d errors\n' "$f" "$(grep -c '\[ERROR\]' "$f")"
done-s (seen in 03-03) discards empty files and the unexpanded pattern in one go.
Solution 3.
shipments=0; issues=0
while IFS=, read -r _ _ _ courier status _; do
[[ "$courier" == "alopez" ]] || continue
(( shipments++ ))
[[ "$status" == "issue" ]] && (( issues++ ))
done < <(tail -n +2 /srv/veloz/data/shipments.csv)
echo "alopez: ${shipments} shipments, ${issues} issues"The key is < <(tail ...): with tail ... | while both counters would come out as 0. Note also the || continue instead of a wrapping if: it removes one indentation level and reads just as well.
Conclusion
You now know how to repeat. for walks known lists —literals, globs or "$@"—, its C-style variant walks indexes, and while/until repeat while a command keeps returning the code you expect. The pattern while IFS= read -r line; do ... done < file is Bash's official file reader, and the rule that comes with it —redirection at the end, never a pipeline— will save you the most frustrating bug in the language, the one where a counter is always 0 because it lived in a subshell.
But notice what happened in the last examples: inside every loop we repeated the same five validation lines and the same output format, only with a different city. We have removed the repetition between invocations of the script, but not inside it. The tool that solves that is functions (04-02): named blocks of code, with their own arguments and their own variables, which will turn validate_env, count_errors and city_summary into pieces you write once and call many times.
Bash Programming Course
Module 1: Introduction to Bash
- What Is Bash?
- Setting Up Your Environment
- Basic Command Line Navigation
- Understanding the Shell
- Finding Help: man, help and --help
Module 2: Basic Bash Commands
- File and Directory Operations
- Text Processing Commands
- File Permissions and Ownership
- Redirection and Piping
- Wildcards and Path Expansion
- History and Keyboard Shortcuts
Module 3: Scripting Fundamentals
- Creating and Running a Script
- Variables and Constants
- Basic Operators
- Conditional Statements
- Arguments and User Input
- Quoting, Expansion and Substitution
Module 4: Intermediate Scripting
- Loops in Bash
- Functions in Bash
- Arrays and Associative Arrays
- String Manipulation
- The case Statement and Interactive Menus
- Arithmetic and Numeric Calculations
Module 5: Advanced Scripting Techniques
- Advanced File Operations
- Process Management
- Error Handling and Debugging
- Regular Expressions
- Advanced I/O: Descriptors and Here-Documents
- Modular Scripts and Reusable Libraries
Module 6: Working with External Tools
Module 7: Automation and Scheduling
- Cron Jobs
- Automating Tasks
- Backup and Restore Scripts
- Monitoring and Logging
- Services and Timers with systemd
- Remote Automation with SSH
Module 8: Best Practices and Optimization
- Writing Readable Code
- Optimizing Bash Scripts
- Security Considerations
- Version Control with Git
- Static Analysis with ShellCheck and shfmt
- Automated Testing with Bats
- Portability: POSIX sh versus Bashisms
