Bash & the POSIX Shell Module 5 – Loops
while, until, break, continue and select
Repeat Bash commands while a test succeeds or until it does, leave nested loops with break 2, retry with growing pauses and build menus with select.
What you will learn
- Write condition-driven loops with while and until, and predict the status they end with
- Use break N and continue N to leave or skip the passes of nested loops
- Build a retry loop with back-off and a final status, a polling loop with a deadline, and a menu with select
Before you start
What you’ll learn · Before you start
What you will learn
- Write condition-driven loops with while and until, and predict the status they end with
- Use break N and continue N to leave or skip the passes of nested loops
- Build a retry loop with back-off and a final status, a polling loop with a deadline, and a menu with select
Before you start
On this page
A retry loop written as for attempt in 1 2 3 4 5; do upload && break; sleep $(( 2 ** attempt )); done pauses for
62 seconds when every upload fails, and then the script carries on with status 0 as if one had worked. The same five
attempts need only 30 seconds of pauses, and they can end with the real failure, once the loop knows when to stop.
You will write loops that run while a command succeeds or until it does, leave or skip the passes of nested loops with
break and continue, build that retry loop and a polling loop with a deadline, and offer a numbered menu with
select.
while runs while a command succeeds, until while it fails
A retry policy that starts at 2 seconds and doubles pauses for 2, 4, 8 and 16 seconds if it stops before 32. A
while loop can print that schedule. It runs its test, which is any command, and while the test ends with
exit status: The number from 0 to 255 every command ends with, kept by the shell in $?. 0 means success; Bash uses 126 for found but not executable and 127 for not found. More in “Your first script: shebang, chmod and running it” 0 it runs the body and tests again. until is the same loop with the test
turned round: it runs the body while the test fails and stops at the first success. The session prints the schedule
with while, with until and once more in dash:
from session import run
run("""
delay=2; while (( delay <= 16 )); do echo "pause ${delay}s"; delay=$(( delay * 2 )); done; echo "after the loop: delay=$delay"
delay=2; until (( delay > 16 )); do echo "pause ${delay}s"; delay=$(( delay * 2 )); done
dash -c 'delay=2; while [ "$delay" -le 16 ]; do echo "pause ${delay}s"; delay=$((delay * 2)); done'
touch report.1 report.2 report.3
n=1; while [[ -e report.$n ]]; do n=$(( n + 1 )); done; echo "next free name: report.$n"
false; while false; do echo "never printed"; done; echo "status after no pass: $?"
check() { return 3; }; tries=0; until check; do echo "check failed with $?"; tries=$(( tries + 1 )); (( tries == 2 )) && break; done
""") Output
$ delay=2; while (( delay <= 16 )); do echo "pause ${delay}s"; delay=$(( delay * 2 )); done; echo "after the loop: delay=$delay"
pause 2s
pause 4s
pause 8s
pause 16s
after the loop: delay=32
$ delay=2; until (( delay > 16 )); do echo "pause ${delay}s"; delay=$(( delay * 2 )); done
pause 2s
pause 4s
pause 8s
pause 16s
$ dash -c 'delay=2; while [ "$delay" -le 16 ]; do echo "pause ${delay}s"; delay=$((delay * 2)); done'
pause 2s
pause 4s
pause 8s
pause 16s
$ touch report.1 report.2 report.3
$ n=1; while [[ -e report.$n ]]; do n=$(( n + 1 )); done; echo "next free name: report.$n"
next free name: report.4
$ false; while false; do echo "never printed"; done; echo "status after no pass: $?"
status after no pass: 0
$ check() { return 3; }; tries=0; until check; do echo "check failed with $?"; tries=$(( tries + 1 )); (( tries == 2 )) && break; done
check failed with 3
check failed with 3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 schedule_session.py
The table follows the first loop one test at a time. The body doubles delay, so the test eventually fails and the
loop ends:
| Test | delay |
(( delay <= 16 )) |
Then |
|---|---|---|---|
| 1 | 2 | status 0 | prints pause 2s |
| 2 | 4 | status 0 | prints pause 4s |
| 3 | 8 | status 0 | prints pause 8s |
| 4 | 16 | status 0 | prints pause 16s |
| 5 | 32 | status 1 | the loop ends; delay stays 32 |
until (( delay > 16 )) printed the same four lines, because “until the delay passes 16” is “while it does not”. The
dash line is the POSIX form of the same counter: (( )) is a Bash command that POSIX does not have, as the
shell arithmetic lesson showed, so a script for sh tests with [ "$delay" -le 16 ]
and computes with $((delay * 2)). A while loop with a counter is also how sh counts when the C-style for of the
for loops lesson is not there.
The test does not have to be arithmetic. while [[ -e report.$n ]] went on adding 1 while a file of that name existed,
and stopped at report.4, the first name nobody had taken.
A loop that never changes its own test
Wrong: n=1; while [[ -e report.$n ]]; do echo "taken: report.$n"; done, which never changes n and repeats the
same line until someone stops it. Right: change what the test reads in every pass, here with n=$(( n + 1 )), and
keep a limit when the condition may never change, as the polling loop below does.
Two rules about statuses come from the manual and POSIX. A loop ends with the status of the last command its body
ran, or 0 when the body never ran: while false ran no pass, so the session printed 0 although false had failed.
The test’s own status is not the loop’s status. Inside the body of until, though, $? still holds the status of
the failed test, which is how until check could print “check failed with 3”, and how a loop can keep it for later.
Key idea
while repeats while its test command succeeds and until while it fails; either loop ends with the status of the last body command it ran, or 0 when it ran none.
Checkpoint
Check: while and until loops
break leaves a loop, continue skips a pass, and a number reaches outer loops
: is a builtin that always succeeds, so while : never stops by itself; break ends it. The session’s first line
counted attempts 1, 2 and 3 and left after the third, the shape to use when the test belongs at the end of the body,
because the shell has no do-while loop. continue does the opposite of finishing: it skips the rest of the body and
starts the next pass, after testing again in while and until, or with the next word in for.
from session import files, run, write
files("search.sh")
write("mon/a.log", "INFO backup started\nINFO backup done\n")
write("mon/b.log", "INFO 12 files copied\n")
write("tue/a.log", "ARCHIVED to cold storage\n")
write("tue/b.log", "ERROR this old line is never read\n")
write("wed/a.log", "INFO backup started\nERROR disk full\n")
write("wed/b.log", "ERROR not reached either\n")
write("thu/a.log", "INFO backup done\n")
run("""
attempt=0; while :; do attempt=$(( attempt + 1 )); echo "attempt $attempt"; (( attempt == 3 )) && break; done; echo "left the loop after $attempt"
for i in 1 2; do for c in a b c; do [ "$c" = b ] && continue; [ "$i" = 2 ] && break 2; echo "$i$c"; done; done; echo "after both loops"
dash -c 'for i in 1 2; do for c in a b c; do [ "$c" = b ] && continue; [ "$i" = 2 ] && break 2; echo "$i$c"; done; done; echo "after both loops"'
bash search.sh
""") Output
$ attempt=0; while :; do attempt=$(( attempt + 1 )); echo "attempt $attempt"; (( attempt == 3 )) && break; done; echo "left the loop after $attempt" attempt 1 attempt 2 attempt 3 left the loop after 3 $ for i in 1 2; do for c in a b c; do [ "$c" = b ] && continue; [ "$i" = 2 ] && break 2; echo "$i$c"; done; done; echo "after both loops" 1a 1c after both loops $ dash -c 'for i in 1 2; do for c in a b c; do [ "$c" = b ] && continue; [ "$i" = 2 ] && break 2; echo "$i$c"; done; done; echo "after both loops"' 1a 1c after both loops $ bash search.sh checked mon/a.log checked mon/b.log skipped tue: archived first error in wed/a.log search finished
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 nested_session.py
In nested loops a number says which loop is meant, counted from the inside. In the second line, continue skipped
the echo when c was b, and break 2 ended both loops as soon as i was 2, so only 1a and 1c were printed, in Bash
and in dash alike. POSIX adds that a number larger than the loops around the command leaves the outermost one. The
four forms, as they act from inside an inner loop:
| Command | What it does from the inner loop |
|---|---|
break |
ends the inner loop; the outer pass goes on after the inner done |
break 2 |
ends both loops; the script goes on after the outer done |
continue |
starts the inner loop’s next pass |
continue 2 |
abandons the rest of this outer pass; the outer loop takes its next value |
search.sh uses both numbers on four folders of logs: a folder whose log starts with ARCHIVED is skipped as a whole,
and the first ERROR line anywhere ends the search.
#!/usr/bin/env bash
# search.sh: finds the first log with an ERROR line, folder by folder.
# A folder whose log starts with ARCHIVED is skipped as a whole.
for day in mon tue wed thu; do
for log in "$day"/*.log; do
if grep -q '^ARCHIVED' "$log"; then
echo "skipped $day: archived"
continue 2
fi
if grep -q ERROR "$log"; then
echo "first error in $log"
break 2
fi
echo "checked $log"
done
done
echo "search finished" Step through the run to see which files the two numbers kept the script from opening:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first file. The outer loop sets day to mon and the inner loop takes mon/a.log. It has no ARCHIVED line and no ERROR line, so the script prints "checked mon/a.log".
- The inner loop ends. mon/b.log is clean too. The inner loop has no files left, so the outer loop takes its next day, tue.
- continue 2 skips a day. tue/a.log starts with ARCHIVED. continue 2 starts the next pass of the second loop out, the day loop, so tue/b.log is never opened, although it holds an ERROR line.
- break 2 ends both loops. wed/a.log holds "ERROR disk full". The script prints "first error in wed/a.log", and break 2 leaves the inner loop and the day loop together.
- What the loops never reached. wed/b.log and thu/a.log are never read. Execution goes on after the outer done, and the last line prints "search finished".
- The rule. Without a number, break and continue act on the innermost loop. With 2 they act on the loop around it, so one test deep inside can skip or end the whole search.
tue/b.log holds an ERROR line, yet the search reported wed/a.log: continue 2 had already moved on to the next
day, as intended for an archived folder. A plain continue there would have opened tue/b.log and stopped the search
on an old error.
One loop too few
Wrong: a plain break after the first error inside the inner loop, which ends only the file loop and lets the day
loop open the next folder. Right: break 2, which leaves both loops at once.
Checkpoint
Check: break and continue, with a number
A retry loop needs a limit, a growing pause and a check after the last try
The opening’s loop shows what goes wrong. The session replaces sleep with a function that prints the pause instead
of waiting, and lets upload fail every time:
from session import files, run
files("retry.sh", "flaky.sh")
run("""
# upload stands in for a command that always fails; this sleep prints the pause instead of waiting
upload() { return 1; }; sleep() { echo "(sleep $1)"; }
for attempt in 1 2 3 4 5; do upload && break; sleep $(( 2 ** attempt )); done; echo "carried on, status $?"
unset -f sleep
RETRY_SLEEP=: bash retry.sh bash flaky.sh
!RETRY_SLEEP=: bash retry.sh false
""") Output
# upload stands in for a command that always fails; this sleep prints the pause instead of waiting
$ upload() { return 1; }; sleep() { echo "(sleep $1)"; }
$ for attempt in 1 2 3 4 5; do upload && break; sleep $(( 2 ** attempt )); done; echo "carried on, status $?"
(sleep 2)
(sleep 4)
(sleep 8)
(sleep 16)
(sleep 32)
carried on, status 0
$ unset -f sleep
$ RETRY_SLEEP=: bash retry.sh bash flaky.sh
flaky: call 1
retry: attempt 1 failed with status 3, next in 2s
flaky: call 2
retry: attempt 2 failed with status 3, next in 4s
flaky: call 3
retry: succeeded on attempt 3
$ RETRY_SLEEP=: bash retry.sh false
retry: attempt 1 failed with status 1, next in 2s
retry: attempt 2 failed with status 1, next in 4s
retry: attempt 3 failed with status 1, next in 8s
retry: attempt 4 failed with status 1, next in 16s
retry: giving up after 5 attempts, last status 1
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 retry_session.py
The naive loop printed five pauses, 2 + 4 + 8 + 16 + 32 = 62 seconds, the last one after the final failure, when no
attempt was left to wait for. Then it reported status 0, because a for loop ends with the status of its last command,
and that was sleep. A script that goes on to delete the local copy would delete it after five failed uploads.
retry.sh fixes all three problems: the wasted last pause, the lost status and the script that carries on. It runs
the command as the test of until, so the body runs only after a failure. The body saves the status, gives up with
that status after attempt 5, and otherwise pauses 2 ** attempt seconds, an
exponential back-off: A retry policy whose pause grows by a fixed factor after every failed attempt, such as 2, 4, 8 and 16 seconds, so that a struggling service gets more time to recover with each try. that gives a struggling service more time with every try:
#!/usr/bin/env bash
# retry.sh COMMAND [ARGUMENT...]: runs COMMAND up to 5 times, pausing 2, 4, 8 and 16 seconds
# between attempts, and exits with the status of the last attempt.
# RETRY_SLEEP=: skips the pauses (the session sets it, so the page shows them without waiting).
max=5
attempt=1
until "$@"; do
status=$?
if (( attempt == max )); then
echo "retry: giving up after $attempt attempts, last status $status" >&2
exit "$status"
fi
pause=$(( 2 ** attempt ))
echo "retry: attempt $attempt failed with status $status, next in ${pause}s" >&2
"${RETRY_SLEEP:-sleep}" "$pause"
attempt=$(( attempt + 1 ))
done
echo "retry: succeeded on attempt $attempt" The session set RETRY_SLEEP=:, so the pauses were printed but not waited. flaky.sh failed twice with status 3 and
succeeded on its third call, after 2 + 4 = 6 seconds of pauses. With false, every attempt failed: the pauses came to
2 + 4 + 8 + 16 = 30 seconds, and the script exited with 1, the status of the last attempt. Step through the calls:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Attempt 1. until runs its test, the command retry.sh was given. flaky.sh prints "flaky: call 1".
- A failure runs the body. flaky.sh fails with status 3. Because the test failed, until runs the body, which saves status=3 and pauses 2 ** 1 = 2 seconds.
- Attempt 2. The test runs again. flaky.sh fails a second time with status 3, and the pause doubles to 2 ** 2 = 4 seconds.
- Attempt 3 succeeds. flaky.sh prints "flaky: call 3" and returns 0. until stops without running the body, and retry.sh prints "succeeded on attempt 3" after 6 seconds of pauses.
- When every attempt fails. With false as the command, attempts 1 to 4 pause 2, 4, 8 and 16 seconds, 30 in all. After attempt 5 the body exits at once with status 1 instead of pausing 32 seconds more.
polling: Checking a state again and again at a fixed interval, such as testing once a second whether a file exists, until it holds or a deadline passes. is the same loop pointed at a state instead of a command’s success.
wait-for-file.sh tests once a second whether a file exists and gives up at a deadline taken from SECONDS, which
Bash keeps as the number of seconds since the shell started, read from the system clock at one-second resolution. So
a limit of 2 is good to about a second. It sleeps whole seconds because POSIX’s sleep promises nothing finer.
#!/usr/bin/env bash
# wait-for-file.sh FILE LIMIT: checks once a second until FILE exists,
# and gives up when LIMIT seconds have passed.
file=$1
limit=$2
deadline=$(( SECONDS + limit ))
until [[ -e $file ]]; do
if (( SECONDS >= deadline )); then
echo "wait-for-file: gave up, $file did not appear" >&2
exit 1
fi
sleep 1
done
echo "wait-for-file: $file is there" In the session it gave up on a file nobody created, with status 1, and found ready.flag once a background job
created it two seconds later:
from session import files, run
files("wait-for-file.sh")
run("""
!bash wait-for-file.sh missing.flag 2
(sleep 2; touch ready.flag) & bash wait-for-file.sh ready.flag 10
""") Output
$ bash wait-for-file.sh missing.flag 2 wait-for-file: gave up, missing.flag did not appear [exit status 1] $ (sleep 2; touch ready.flag) & bash wait-for-file.sh ready.flag 10 wait-for-file: ready.flag is there
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 wait_session.py
If you keep the for form
Record the outcome before the loop ends: ok=0; for attempt in 1 2 3; do if upload; then ok=1; break; fi; done,
then (( ok )) || exit 1. The test after the loop is what keeps the script from going on as if it had worked.
Checkpoint
Check: Retry and polling loops
select prints a numbered menu and puts the answer in REPLY
select turns a list of words into a menu. It prints each word with a number on standard error, prompts with PS3,
and reads one line from standard input. When the line is the number of a word, the loop variable takes that word, any
other text empties it, and the line itself always lands in REPLY. The body runs after every line that is not empty,
and the loop prompts again until a break ends it. Here is a menu of three service actions:
#!/usr/bin/env bash
# menu.sh: a numbered menu of service actions; the choice comes from standard input.
PS3='action number: '
select action in start stop status; do
if [[ -n $action ]]; then
echo "chose $action (REPLY=$REPLY)"
break
fi
echo "no action has the number $REPLY"
done The session feeds the answers through a pipe, so the typed numbers do not appear after the prompt:
from session import files, run
files("menu.sh")
run("""
printf '2\\n' | bash menu.sh
printf '9\\n\\n3\\n' | bash menu.sh
printf '1\\n' | bash menu.sh 2> /dev/null
!bash menu.sh < /dev/null
!dash menu.sh
""") Output
$ printf '2\n' | bash menu.sh 1) start 2) stop 3) status action number: chose stop (REPLY=2) $ printf '9\n\n3\n' | bash menu.sh 1) start 2) stop 3) status action number: no action has the number 9 action number: 1) start 2) stop 3) status action number: chose status (REPLY=3) $ printf '1\n' | bash menu.sh 2> /dev/null chose start (REPLY=1) $ bash menu.sh < /dev/null 1) start 2) stop 3) status action number: [exit status 1] $ dash menu.sh menu.sh: 4: Syntax error: "do" unexpected [exit status 2]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 menu_session.py
Each input line had its own effect, as the table shows:
| Input | action |
REPLY |
What select did |
|---|---|---|---|
| 2 | stop | 2 | ran the body, which printed the choice and broke out |
| 9 | empty | 9 | ran the body, then printed only the prompt again |
| an empty line | unchanged | empty | printed the menu and the prompt again, without the body |
| end of input | unchanged | empty | ended the loop with status 1 |
The menu came back only after the empty line: since bash-2.05b, select reprints it only when REPLY is empty, as its
NEWS file records. 2> /dev/null hid the menu and the prompt and left “chose start”, which shows they go to standard
error, so a script’s output stays clean when its menu is used. dash stopped at do with a syntax error and status 2:
POSIX lists select among the words a shell may reserve with unspecified results, and dash has no such loop.
Checkpoint
Check: Menus with select
Practise: loops that stop on time
These questions move the lesson’s loops onto new cases: other file names, a step that jumps over its target, nested
loops with continue 2, a retry whose pauses grow as 3 ** attempt, and menu.sh at end of input. Work out what
each prints, or how long it waits, before you check your answer:
Practice that adapts to you
Practise this lesson
16 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): how do while and until differ, and what is while : for?
Both run a test command before every pass. while runs the body as long as the test succeeds, with status 0, and
until runs it as long as the test fails, so until cmd reads as “while not cmd”. The colon is a builtin that always
succeeds, so while : loops until a break inside the body ends it: that is how the shell writes a loop whose exit
test sits at the end, since it has no do-while. Either loop ends with the status of the last body command it ran, or 0
when the body never ran, so a loop’s status does not say whether its condition ever held.
Two harder questions follow: one about deciding which failures a retry wrapper should retry and how long it may take
in all, and one about a search whose break stopped working after a refactor. Decide what you would check first, then
open each first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A release script downloads an artefact with curl --fail inside a loop of 6 attempts with doubling pauses. It retries a 404 for a wrong version number as patiently as a 503, and one run hung for 40 minutes on a stalled transfer. How would you decide what to retry, and bound the total time?
-
Fresher to mid
Nested loops over hosts and ports stopped with break 2 at the first port that answered. A refactor moved the check and its break 2 into a function, try_port. Now every host and port is tried, and Bash warns that break is "only meaningful" in a loop. Why, and how do you fix it?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How do while and until differ?
Both run a test command before every pass. while repeats the body as long as the test succeeds, until as long as it fails, so until cmd reads as while not cmd.
-
Which status does a while or until loop end with?
The status of the last body command it ran, or 0 when the body never ran.
-
What do break, continue and break 2 do?
break ends the innermost loop and continue starts its next pass at once. With a number, break 2 or continue 2 acts on the loop around it.
-
How does the shell write a loop whose exit test sits at the end?
while : with a break inside the body. The colon always succeeds, so only the break ends the loop; the shell has no do-while.
-
What does a sound retry loop need?
A limit on attempts, a pause that grows after each failure, no pause after the last attempt, and the last attempt's status at the end so the caller can see the failure.
-
How does a polling loop avoid waiting for ever?
It checks the state at a fixed interval and also stops at a deadline, for example one counted with Bash's SECONDS variable.
-
What does select do with the answer it reads?
It prints a numbered menu on standard error, prompted by PS3, and reads a line into REPLY. A listed number sets the loop variable to that word, any other text empties it, and an empty line shows the menu again.
-
When does a select loop end, and which shells have it?
It goes on until a break, or until input ends, when it finishes with status 1. Bash has it; POSIX sh and dash do not.
Key takeaways
whileruns its body while a test command succeeds anduntilwhile it fails; the test can be any command, and the loop ends with the status of the last body command, or 0 when no pass ran.breakends the innermost loop andcontinuestarts its next pass;break 2andcontinue 2act on the loop around it, andwhile :with abreakis the shell’s loop whose test sits at the end.- A retry loop needs a limit, a pause that grows, no pause after the last attempt and the last attempt’s status at the
end, which
until "$@"with an exit inside the body gives; a polling loop adds a deadline such as one fromSECONDS. selectprints a numbered menu on standard error and reads a line from standard input intoREPLY: a listed number sets its variable to that word, any other text empties it, an empty line brings the menu back, and the loop goes on untilbreak, in Bash but not in POSIX sh or dash.
Terms in this lesson
- exponential back-off
- A retry policy whose pause grows by a fixed factor after every failed attempt, such as 2, 4, 8 and 16 seconds, so that a struggling service gets more time to recover with each try.
- polling
- Checking a state again and again at a fixed interval, such as testing once a second whether a file exists, until it holds or a deadline passes.
References
- Bash Reference Manual: Looping Constructs (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Conditional Constructs (select) (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Bourne Shell Builtins (break and continue) (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Bash Variables (SECONDS, REPLY, PS3) (Chet Ramey (GNU Bash maintainer))
- Bash NEWS (bash-2.05b: when select prints its menu again) (Chet Ramey (GNU Bash maintainer))
- POSIX.1-2024: Shell Command Language (2.9.4.5 The while Loop) (The Open Group and IEEE)
- POSIX.1-2024: Shell Command Language (2.9.4.6 The until Loop) (The Open Group and IEEE)
- POSIX.1-2024: break (special built-in) (The Open Group and IEEE)
- POSIX.1-2024: continue (special built-in) (The Open Group and IEEE)
- POSIX.1-2024: Shell Command Language (2.4 Reserved Words) (The Open Group and IEEE)
- POSIX.1-2024: sleep (The Open Group and IEEE)
Report a problem with this lesson
Kept only in this browser. Your Learn progress