Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ for (( s=1; s<=5; s++ )); do printf '%s ' "$s"; done; echo
1 2 3 4 5

Three 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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 3

Both 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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/5

And all three slots are optional. Leave them all out for a deliberate infinite loop:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ for (( ; ; )); do echo tick; break; done
tick

That 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ /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.25

The 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ /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 size

That 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ for (( i=0; i<0; i++ )); do echo BODY; done; echo "status=$?"
status=0

The 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ i=0; (( ++i )); echo "status=$?, i=$i"
status=0, i=1

This 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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 $?
1

Note 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 for set -e to see:

    Azvya Education Pvt. Ltd.VLSI Mentor
    Snippet
    $ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/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"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./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 $?
2

Five 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
if "$@"; then
  exit 0
fi
rc=$?          # WRONG

That 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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 = 1

In 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

  1. Run for (( i=0; i<5; i++ )) and for (( i=5; i>0; i-- )), printing $i. Then write one that steps by 3 from 1 to 20 and stops without overshooting.
  2. Write the comma form that walks i up and j down and stops when they meet. What is the last pair printed for a 10-element range, and why is it not 5/4?
  3. Set n=6. Run for i in {1..$n}, then the seq version, then the C-style version. Name the expansion-order rule that explains the first.
  4. Measure the seq and 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.
  5. Run i=0; (( i++ )); echo $? and i=0; (( ++i )); echo $?. Explain the different statuses using post- versus pre-increment and the (( )) truth rule.
  6. Run bash -c 'set -e; i=0; (( i++ )); echo survived' and the same with let i++. Which dies? Now write the increment you would actually ship.
  7. Run for (( i=0; i<0; i++ )); do echo BODY; done; echo $?. Then add the validation that makes an empty count an error.
  8. Give retry.sh a command that fails twice and then succeeds — a script that counts its own invocations in a file. Confirm the success message names attempt 3.
  9. Remove the if (( a < attempts )) guard from retry.sh and time ./retry.sh 3 false. How much longer does it take, and what did the user gain?
  10. Move rc=$? from inside the else to just after fi, then run ./retry.sh 2 ./e3.sh where e3.sh exits 3. What status does the script return, and which chapter predicted it?

Check Your Understanding

  1. When is a C-style for the right choice over a list for?
  2. Why is $ optional on variables inside (( ))?
  3. Why can't you write {1..$n}?
  4. i=0; (( i++ )) reports status 1. Did the increment work, and why is the status 1?
  5. Which of (( i++ )) and let i++ aborts a script under set -e on this machine?
  6. What increment form is immune to all of that, and why?
  7. 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; seq is 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 when i was 0 — post-increment yields the old value, and (( )) calls zero false.
  • let i++ aborts under set -e; (( i++ )) does not on this machine. Avoid let entirely.
  • 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 -e never 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.