Linux & Bash · Part I · Module 17 · Loops and Iteration
C-Style for and Counting Loops
Sometimes you do not have a list. You have a count — run ten seeds, retry three times, step through a range — and building a list of words just to count through it is the wrong shape. Bash has a second for loop for exactly this, borrowed from C, and it avoids a trap that the hand-rolled version walks straight into.
Intermediate17 min readLinuxPart IBashLoopsArithmeticScripting
Part I · Module 17 · Chapter 17.3 · C-Style for and Counting Loops
Chapter 17.1's for iterates a list. That is the right shape when you genuinely have one — tests, files, tool names. It is the wrong shape when what you have is a number.
1. The Form
$ for (( s=1; s<=5; s++ )); do printf '%s ' "$s"; done; echo
1 2 3 4 5Three arithmetic expressions in double parentheses, separated by semicolons: an initialisation, a condition tested before each pass, and an update run after each pass. Anyone who has written C, Java, JavaScript or Verilog already knows it.
The contents are an arithmetic context — Chapter 8.5's (( )), the same one Chapter 16.3 used as a condition. Which means the ordinary operators work and $ on a variable is optional:
$ for (( i=10; i>0; i-=3 )); do printf '%s ' "$i"; done; echo
10 7 4 1
$ n=4
$ for (( i=0; i<n; i++ )); do printf '%s ' "$i"; done; echo
0 1 2 3
$ for (( i=0; i<$n; i++ )); do printf '%s ' "$i"; done; echo
0 1 2 3Both forms of the condition work. Write n without the $ — inside an arithmetic context a bare name is read as a variable, and omitting the sigil is the idiom.
Commas let each slot hold several expressions:
$ for (( i=0, j=9; i<j; i++, j-- )); do printf '%s/%s ' "$i" "$j"; done; echo
0/9 1/8 2/7 3/6 4/5And all three slots are optional. Leave them all out for a deliberate infinite loop:
$ for (( ; ; )); do echo tick; break; done
tickThat is the same thing while true does (Chapter 17.4), spelled differently. Either is fine; while true reads better to most people.
2. Why Not Just Build a List
Chapter 17.1 gave three ways to count, and this is the argument for preferring this one.
for i in {1..5}; do ... # cannot take a variable bound
for i in $(seq 1 "$n"); do ... # a process, and a list in memory
for (( i=1; i<=n; i++ )); do ... # neither{1..$n} does not work at all. Chapter 17.1 §4 measured it: brace expansion runs before parameter expansion, so the braces see the literal $n, decline to expand, and you get one iteration on the six-character text {1..5}. There is no bound you can put in braces that comes from a variable.
$(seq 1 "$n") works. It forks a process, seq writes every number, Bash reads them back as text, splits them into words, and builds the whole list in memory before the first iteration.
It would be tidy to claim the arithmetic form is therefore faster. It is not. Measured at fifty thousand iterations, three runs each, and the result was stable:
$ /usr/bin/time -p bash -c 'for i in $(seq 1 50000); do :; done'
user 0.12
$ /usr/bin/time -p bash -c 'for (( i=1; i<=50000; i++ )); do :; done'
user 0.25The arithmetic version uses about twice the CPU, consistently. That makes sense once you look at what each one does per pass: the list loop assigns a word that is already sitting in memory, while the arithmetic loop evaluates a comparison and an increment in the shell's own interpreter every single time. One fork up front is cheap; fifty thousand interpreted expressions are not.
Where the list form loses badly is memory, because the entire list is materialised before iteration one:
$ /usr/bin/time -l bash -c 'for i in $(seq 1 500000); do :; done' 2>&1 | grep 'maximum resident'
31256576 maximum resident set size
$ /usr/bin/time -l bash -c 'for (( i=1; i<=500000; i++ )); do :; done' 2>&1 | grep 'maximum resident'
2031616 maximum resident set sizeThat is 29.8 MB against 1.9 MB.
So speed is not the argument — these three are. {1..$n} cannot take a variable at all. seq is not POSIX and is missing on some minimal systems. And for (( i=1; i<=n; i++ )) says count from 1 to n in one line, with the bound as a variable and nothing materialised. Reach for it because it is correct and clear, not because it is fast.
3. An Empty Range Runs Zero Times
$ for (( i=0; i<0; i++ )); do echo BODY; done; echo "status=$?"
status=0The condition is tested before the first pass, so a range that is empty from the start runs the body no times and reports success — the same behaviour as an empty list in Chapter 17.1 §5, and the same caveat: a loop that was meant to run ten times and ran zero looks like success to the caller.
if (( runs < 1 )); then
echo "${0##*/}: runs must be at least 1" >&2
exit 2
fi
for (( i=1; i<=runs; i++ )); do ...Validate the count before you loop on it. That is Chapter 16.3's numeric comparison doing the job for will not do for you.
4. The Increment That Reports Failure
Here is the measurement worth the chapter, and it explains why the C-style form is more than sugar.
$ i=0; (( i++ )); echo "status=$?, i=$i"
status=1, i=1
$ i=1; (( i++ )); echo "status=$?, i=$i"
status=0, i=2(( i++ )) returns 1 when i was 0. The increment worked — i is 1 afterwards — and the status says failure.
Both halves of Chapter 16.3 §5 are needed to see why. i++ is post-increment, so the expression's value is the old i, which was 0. And (( )) reports success when the value is non-zero and failure when it is zero. So a perfectly successful increment from zero evaluates to 0, which (( )) converts to status 1.
The pre-increment form does not have this, because its value is the new number:
$ i=0; (( ++i )); echo "status=$?, i=$i"
status=0, i=1This is a curiosity until you add set -e, and then it is a script that dies for no reason. Measured both ways on this machine:
$ bash -c 'set -e; i=0; (( i++ )); echo "survived, i=$i"'
survived, i=1
$ bash -c 'set -e; i=0; let i++; echo survived'
$ echo $?
1Note what did not happen there: no error message at all. survived never printed, the shell exited 1, and nothing said why. That is the whole hazard — set -e plus let on a variable that happens to be zero kills a script silently, in a line that looks like arithmetic rather than a decision.
(( i++ )) does not abort here; let i++ does. The two spellings do the same arithmetic and differ on whether they kill your script. Bash's rules about which constructs set -e ignores are genuinely intricate and have varied between versions — Chapter 10.6 and Chapter 20.2 are where that is treated properly — so the practical advice is short:
-
In a counting loop, use
for (( )). Its update is part of the loop's syntax, not a command, so there is no status forset -eto see:Azvya Education Pvt. Ltd.VLSI MentorSnippet$ bash -c 'set -e; for (( i=0; i<3; i++ )); do printf "%s " "$i"; done; echo "| fine"' 0 1 2 | fine -
Where you must increment by hand, write
i=$(( i + 1 )). It is an assignment, assignments always succeed, and it is immune to all of the above. Every script in this module uses that form. -
Avoid
let. It adds nothing over(( ))and is the one spelling measured to abort.
5. A Complete Script
A retry wrapper — the canonical use of a counting loop, because the thing you are counting is attempts:
#!/usr/bin/env bash
# retry.sh — run a command until it succeeds, up to N times, backing off
# usage: retry.sh <attempts> <command> [args...]
#
# exit: the command's status on success · 2 bad usage
# the last failure's status if every attempt failed
set -o nounset
attempts=${1:-}
shift || true
if [ -z "$attempts" ] || [ $# -eq 0 ]; then
echo "usage: ${0##*/} <attempts> <command> [args...]" >&2
exit 2
fi
if ! [ "$attempts" -ge 1 ] 2>/dev/null; then
echo "${0##*/}: attempts must be a positive integer, got: $attempts" >&2
exit 2
fi
rc=0
for (( a=1; a<=attempts; a++ )); do
if "$@"; then
if (( a > 1 )); then
printf '%s: succeeded on attempt %d of %d\n' "${0##*/}" "$a" "$attempts" >&2
fi
exit 0
else
rc=$? # inside the else, so this is the COMMAND's status
fi
# no sleep after the final attempt — nobody is waiting for it
if (( a < attempts )); then
delay=$(( a * 2 ))
printf '%s: attempt %d of %d failed (status %d), retrying in %ds\n' \
"${0##*/}" "$a" "$attempts" "$rc" "$delay" >&2
sleep "$delay"
fi
done
printf '%s: all %d attempts failed\n' "${0##*/}" "$attempts" >&2
exit "$rc"$ ./retry.sh 3 true
$ echo $?
0
$ ./retry.sh 3 false
retry.sh: attempt 1 of 3 failed (status 1), retrying in 2s
retry.sh: attempt 2 of 3 failed (status 1), retrying in 4s
retry.sh: all 3 attempts failed
$ echo $?
1
$ printf '#!/bin/bash\nexit 3\n' > e3.sh && chmod +x e3.sh
$ ./retry.sh 2 ./e3.sh
retry.sh: attempt 1 of 2 failed (status 3), retrying in 2s
retry.sh: all 2 attempts failed
$ echo $?
3
$ ./retry.sh 2 ls /nope
ls: /nope: No such file or directory
retry.sh: attempt 1 of 2 failed (status 1), retrying in 2s
ls: /nope: No such file or directory
retry.sh: all 2 attempts failed
$ echo $?
1
$ ./retry.sh 0 true
retry.sh: attempts must be a positive integer, got: 0
$ echo $?
2
$ ./retry.sh abc true
retry.sh: attempts must be a positive integer, got: abc
$ echo $?
2Five decisions, each from a specific chapter:
for (( a=1; a<=attempts; a++ )) — the thing being iterated is a count, and the bound is a variable. Neither {1..$attempts} nor a list would serve.
if (( a < attempts )) guards the sleep. Without it the script sleeps after the last failure, delaying the failure report for nothing. This is the detail most hand-written retry loops get wrong.
rc=$? is inside the else, and that placement is the whole bug this script had when it was first written. The obvious spelling puts it after fi:
if "$@"; then
exit 0
fi
rc=$? # WRONGThat reports 0 for every failure, so retry.sh 3 false exits 0 and tells the caller the command succeeded. Chapter 16.1 §6 is exactly why: $? after fi is the status of whichever branch body ran, not the condition's — and here no body ran, which reports 0. Measured:
$ bash -c 'if false; then echo y; fi; rc=$?; echo "rc after fi = $rc"'
rc after fi = 0
$ bash -c 'if false; then echo y; else rc=$?; fi; echo "rc in else = $rc"'
rc in else = 1In the else branch, $? is still the condition's status because the condition was the last thing that ran. That is the only place to capture it, and it is why the script exits with the real reason — status 3 above, not a flattened 1.
[ "$attempts" -ge 1 ] 2>/dev/null validates the count. Chapter 16.3 measured that [ reports a non-numeric operand as status 2 with a message on stderr; discarding the message and testing the status turns that into a clean validation. (( )) would have silently treated abc as 0.
"$@" runs the command. Quoted, so an argument containing a space survives — Chapter 15.4's rule, and the reason retry.sh 3 grep "my pattern" file works.
Practice
- Run
for (( i=0; i<5; i++ ))andfor (( i=5; i>0; i-- )), printing$i. Then write one that steps by 3 from 1 to 20 and stops without overshooting. - Write the comma form that walks
iup andjdown and stops when they meet. What is the last pair printed for a 10-element range, and why is it not5/4? - Set
n=6. Runfor i in {1..$n}, then theseqversion, then the C-style version. Name the expansion-order rule that explains the first. - Measure the
seqand arithmetic versions over 50,000 iterations with/usr/bin/time -p, three runs each, and compare user time. Which is faster? Now measure peak memory at 500,000 with/usr/bin/time -l. Write the one-sentence recommendation those two measurements together support. - Run
i=0; (( i++ )); echo $?andi=0; (( ++i )); echo $?. Explain the different statuses using post- versus pre-increment and the(( ))truth rule. - Run
bash -c 'set -e; i=0; (( i++ )); echo survived'and the same withlet i++. Which dies? Now write the increment you would actually ship. - Run
for (( i=0; i<0; i++ )); do echo BODY; done; echo $?. Then add the validation that makes an empty count an error. - Give
retry.sha command that fails twice and then succeeds — a script that counts its own invocations in a file. Confirm the success message names attempt 3. - Remove the
if (( a < attempts ))guard fromretry.shand time./retry.sh 3 false. How much longer does it take, and what did the user gain? - Move
rc=$?from inside theelseto just afterfi, then run./retry.sh 2 ./e3.shwheree3.shexits 3. What status does the script return, and which chapter predicted it?
Check Your Understanding
- When is a C-style
forthe right choice over a listfor? - Why is
$optional on variables inside(( ))? - Why can't you write
{1..$n}? i=0; (( i++ ))reports status 1. Did the increment work, and why is the status 1?- Which of
(( i++ ))andlet i++aborts a script underset -eon this machine? - What increment form is immune to all of that, and why?
- An empty range reports success. What should a script do about that?
6. Remember
for (( init; cond; update ))is the counting loop. The contents are an arithmetic context, so$is optional and the ordinary operators work.- All three slots are optional;
for (( ; ; ))is an infinite loop, and commas let a slot hold several expressions. {1..$n}is impossible — brace expansion precedes parameter expansion.for (( ))is the answer;seqis a process and not POSIX.- The arithmetic form is not faster — measured at ~2× the CPU of
seq, because it interprets two expressions per pass. It wins on memory (1.9 MB vs 29.8 MB at 500k), portability and correctness. Choose it for those. - Use an index only when the number is the point. If you have a list, iterate the list.
- The condition is tested first, so an empty range runs zero times and returns 0. Validate the count yourself.
(( i++ ))returns 1 wheniwas 0 — post-increment yields the old value, and(( ))calls zero false.let i++aborts underset -e;(( i++ ))does not on this machine. Avoidletentirely.i=$(( i + 1 ))is the increment to ship. It is an assignment, so it always succeeds.- A C-style loop's update is syntax, not a command, so
set -enever sees a status from it.
Chapter 17.4 is the loop for when you do not know the count — repeat while something is true, including the pattern that waits for another job to finish without holding a processor core at full load for the entire wait.
Where this fits
Part of the Linux & Bash curriculum.
